612 lines
25 KiB
C#
612 lines
25 KiB
C#
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using AspNetCoreRateLimit;
|
|
using HealthChecks.UI.Client;
|
|
using Managing.Api.Authorization;
|
|
using Managing.Api.Filters;
|
|
using Managing.Api.HealthChecks;
|
|
using Managing.Application.Abstractions.Services;
|
|
using Managing.Application.Hubs;
|
|
using Managing.Bootstrap;
|
|
using Managing.Common;
|
|
using Managing.Core.Middleawares;
|
|
using Managing.Infrastructure.Databases.InfluxDb.Models;
|
|
using Managing.Infrastructure.Databases.PostgreSql;
|
|
using Managing.Infrastructure.Databases.PostgreSql.Configurations;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
|
using Microsoft.AspNetCore.Http.Features;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Microsoft.OpenApi.Models;
|
|
using NSwag;
|
|
using NSwag.Generation.Processors.Security;
|
|
using Serilog;
|
|
using Serilog.Events;
|
|
using Serilog.Sinks.Elasticsearch;
|
|
using OpenApiSecurityRequirement = Microsoft.OpenApi.Models.OpenApiSecurityRequirement;
|
|
using OpenApiSecurityScheme = NSwag.OpenApiSecurityScheme;
|
|
using DotNetEnv;
|
|
|
|
// Optionally load .env file if it exists (primarily for Vibe Kanban worktrees)
|
|
// This is optional - if no .env file exists, the app will use system env vars and appsettings.json
|
|
// This must happen before WebApplication.CreateBuilder to ensure env vars are available
|
|
var enableEnvFile = Environment.GetEnvironmentVariable("ENABLE_ENV_FILE") != "false"; // Can be disabled via env var
|
|
|
|
if (enableEnvFile)
|
|
{
|
|
// Try multiple locations: current directory, project root, and solution root
|
|
var envFilePaths = new[]
|
|
{
|
|
Path.Combine(Directory.GetCurrentDirectory(), ".env"), // Current working directory
|
|
Path.Combine(AppContext.BaseDirectory, ".env"), // Executable directory
|
|
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".env")), // Project root (from bin/Debug/net8.0)
|
|
Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".env")), // Current directory (absolute)
|
|
};
|
|
|
|
string? loadedEnvPath = null;
|
|
foreach (var envPath in envFilePaths)
|
|
{
|
|
if (File.Exists(envPath))
|
|
{
|
|
try
|
|
{
|
|
Env.Load(envPath);
|
|
loadedEnvPath = envPath;
|
|
Console.WriteLine($"✅ Loaded .env file from: {envPath} (optional - for Vibe Kanban worktrees)");
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"⚠️ Failed to load .env file from {envPath}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Silently continue if no .env file found - this is expected in normal operation
|
|
// .env file is only needed for Vibe Kanban worktrees
|
|
}
|
|
|
|
// Builder
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Configuration.SetBasePath(AppContext.BaseDirectory);
|
|
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
|
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json")
|
|
.AddJsonFile($"config.{builder.Environment.EnvironmentName}.json",
|
|
optional: true, reloadOnChange: true);
|
|
|
|
builder.Configuration.AddEnvironmentVariables();
|
|
builder.Configuration.AddUserSecrets<Program>();
|
|
|
|
// Initialize Sentry conditionally based on DSN availability
|
|
var sentryDsn = builder.Configuration["Sentry:Dsn"];
|
|
if (!string.IsNullOrWhiteSpace(sentryDsn))
|
|
{
|
|
SentrySdk.Init(options =>
|
|
{
|
|
// A Sentry Data Source Name (DSN) is required.
|
|
// See https://docs.sentry.io/concepts/key-terms/dsn-explainer/
|
|
// You can set it in the SENTRY_DSN environment variable, or you can set it in code here.
|
|
options.Dsn = sentryDsn;
|
|
|
|
// When debug is enabled, the Sentry client will emit detailed debugging information to the console.
|
|
// This might be helpful, or might interfere with the normal operation of your application.
|
|
// We enable it here for demonstration purposes when first trying Sentry.
|
|
// You shouldn't do this in your applications unless you're troubleshooting issues with Sentry.
|
|
options.Debug = false;
|
|
|
|
// Adds request URL and headers, IP and name for users, etc.
|
|
options.SendDefaultPii = true;
|
|
|
|
// This option is recommended. It enables Sentry's "Release Health" feature.
|
|
options.AutoSessionTracking = true;
|
|
|
|
// Enabling this option is recommended for client applications only. It ensures all threads use the same global scope.
|
|
options.IsGlobalModeEnabled = false;
|
|
|
|
// Example sample rate for your transactions: captures 10% of transactions
|
|
options.TracesSampleRate = 0.1;
|
|
|
|
options.Environment = builder.Environment.EnvironmentName;
|
|
});
|
|
}
|
|
|
|
builder.Services.AddHealthChecks()
|
|
.AddCheck("self", () => HealthCheckResult.Healthy(), ["api"]);
|
|
|
|
var postgreSqlConnectionString = builder.Configuration.GetSection(Constants.Databases.PostgreSql)["ConnectionString"];
|
|
var influxUrl = builder.Configuration.GetSection(Constants.Databases.InfluxDb)["Url"];
|
|
var web3ProxyUrl = builder.Configuration.GetSection("Web3Proxy")["BaseUrl"];
|
|
var kaigenBaseUrl = builder.Configuration.GetSection("Kaigen")["BaseUrl"];
|
|
|
|
// Add HTTP client for Web3Proxy health check with detailed response
|
|
builder.Services.AddHttpClient("Web3ProxyHealthCheck")
|
|
.ConfigureHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(15); });
|
|
|
|
// Add HTTP client for Kaigen API health check
|
|
builder.Services.AddHttpClient("KaigenHealthCheck")
|
|
.ConfigureHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(15); });
|
|
|
|
// Add HTTP client for GMX API health check
|
|
builder.Services.AddHttpClient("GmxHealthCheck")
|
|
.ConfigureHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(10); });
|
|
|
|
// Register Web3ProxyHealthCheck with the web3ProxyUrl
|
|
builder.Services.AddSingleton<Web3ProxyHealthCheck>(sp =>
|
|
new Web3ProxyHealthCheck(sp.GetRequiredService<IHttpClientFactory>(), web3ProxyUrl));
|
|
|
|
// Register KaigenHealthCheck with the kaigenBaseUrl
|
|
builder.Services.AddSingleton<KaigenHealthCheck>(sp =>
|
|
new KaigenHealthCheck(sp.GetRequiredService<IHttpClientFactory>(), kaigenBaseUrl));
|
|
|
|
// Add SQL Loop Detection Service with Sentry integration
|
|
// Configure SQL monitoring settings
|
|
builder.Services.Configure<SqlMonitoringSettings>(builder.Configuration.GetSection("SqlMonitoring"));
|
|
|
|
// Register SQL monitoring services
|
|
builder.Services.AddSingleton<SentrySqlMonitoringService>();
|
|
|
|
// Add PostgreSQL DbContext with improved concurrency and connection management
|
|
builder.Services.AddDbContext<ManagingDbContext>((serviceProvider, options) =>
|
|
{
|
|
options.UseNpgsql(postgreSqlConnectionString, npgsqlOptions =>
|
|
{
|
|
// Configure connection pooling and timeout settings for better concurrency
|
|
npgsqlOptions.CommandTimeout(60); // Increase command timeout for complex queries
|
|
npgsqlOptions.EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(10),
|
|
errorCodesToAdd: null);
|
|
});
|
|
|
|
// Enable detailed errors in development
|
|
if (builder.Environment.IsDevelopment())
|
|
{
|
|
options.EnableDetailedErrors();
|
|
options.EnableSensitiveDataLogging();
|
|
options.EnableThreadSafetyChecks(); // Enable thread safety checks in development
|
|
}
|
|
|
|
// Configure query tracking behavior for better performance
|
|
options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); // Default to no tracking for better performance
|
|
|
|
// Enable service provider caching for better performance
|
|
options.EnableServiceProviderCaching();
|
|
|
|
// Disable SQL query logging to reduce log volume (only errors will be logged via standard logging)
|
|
// SQL monitoring is handled by SentrySqlMonitoringService which respects LogSlowQueriesOnly setting
|
|
}, ServiceLifetime.Scoped); // Explicitly specify scoped lifetime for proper request isolation
|
|
|
|
// Add specific health checks for databases and other services
|
|
builder.Services.AddHealthChecks()
|
|
.AddNpgSql(postgreSqlConnectionString, name: "postgresql", tags: ["database"])
|
|
.AddUrlGroup(new Uri($"{influxUrl}/health"), name: "influxdb", tags: ["database"])
|
|
.AddCheck<Web3ProxyHealthCheck>("web3proxy", tags: ["api", "external"])
|
|
.AddCheck<CandleDataHealthCheck>("candle-data", tags: ["database", "candles"])
|
|
.AddCheck<CandleDataDetailedHealthCheck>("candle-data-detailed", tags: ["database", "candles-detailed"])
|
|
.AddCheck<GmxConnectivityHealthCheck>("gmx-connectivity", tags: ["api", "external"])
|
|
.AddCheck<KaigenHealthCheck>("kaigen-api", tags: ["api", "external"])
|
|
.AddCheck<OrleansHealthCheck>("orleans-cluster", tags: ["orleans", "cluster"]);
|
|
|
|
builder.Host.UseSerilog((hostBuilder, loggerConfiguration) =>
|
|
{
|
|
var envName = builder.Environment.EnvironmentName.ToLower().Replace(".", "-");
|
|
var indexFormat = $"managing-{envName}-" + "{0:yyyy.MM.dd}";
|
|
var yourTemplateName = "dotnetlogs";
|
|
var es = new ElasticsearchSinkOptions(new Uri(hostBuilder.Configuration["ElasticConfiguration:Uri"]))
|
|
{
|
|
IndexFormat = indexFormat.ToLower(),
|
|
AutoRegisterTemplate = true,
|
|
OverwriteTemplate = true,
|
|
TemplateName = yourTemplateName,
|
|
AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv7,
|
|
TypeName = null,
|
|
BatchAction = ElasticOpType.Create,
|
|
MinimumLogEventLevel = LogEventLevel.Information,
|
|
DetectElasticsearchVersion = true,
|
|
RegisterTemplateFailure = RegisterTemplateRecovery.IndexAnyway,
|
|
};
|
|
|
|
loggerConfiguration
|
|
.MinimumLevel
|
|
.Override("Microsoft.EntityFrameworkCore.Database.Command",
|
|
LogEventLevel.Warning) // Filter out EF Core SQL query logs
|
|
.WriteTo.Console()
|
|
.WriteTo.Elasticsearch(es);
|
|
});
|
|
|
|
builder.Services.AddOptions();
|
|
builder.Services.Configure<PostgreSqlSettings>(builder.Configuration.GetSection(Constants.Databases.PostgreSql));
|
|
builder.Services.Configure<InfluxDbSettings>(builder.Configuration.GetSection(Constants.Databases.InfluxDb));
|
|
|
|
// Configure Request Size Limits (protect against DoS attacks)
|
|
builder.Services.Configure<FormOptions>(options =>
|
|
{
|
|
options.MultipartBodyLengthLimit = 10485760; // 10MB
|
|
options.ValueLengthLimit = 10485760; // 10MB
|
|
options.MultipartBoundaryLengthLimit = 256;
|
|
options.KeyLengthLimit = 2048;
|
|
});
|
|
|
|
builder.Services.Configure<KestrelServerOptions>(options =>
|
|
{
|
|
options.Limits.MaxRequestBodySize = 10485760; // 10MB
|
|
options.Limits.MaxRequestHeadersTotalSize = 32768; // 32KB
|
|
options.Limits.MaxConcurrentConnections = 100;
|
|
options.Limits.MaxConcurrentUpgradedConnections = 100;
|
|
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
|
});
|
|
|
|
// Configure Rate Limiting
|
|
builder.Services.AddMemoryCache();
|
|
builder.Services.Configure<IpRateLimitOptions>(builder.Configuration.GetSection("IpRateLimiting"));
|
|
builder.Services.Configure<IpRateLimitPolicies>(builder.Configuration.GetSection("IpRateLimitPolicies"));
|
|
builder.Services.AddInMemoryRateLimiting();
|
|
builder.Services.AddSingleton<IRateLimitConfiguration, RateLimitConfiguration>();
|
|
|
|
builder.Services.AddControllers().AddJsonOptions(options =>
|
|
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
|
|
// Validate JWT secret configuration
|
|
var jwtSecret = builder.Configuration["Jwt:Secret"];
|
|
if (string.IsNullOrWhiteSpace(jwtSecret))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"JWT secret is not configured. Please set 'Jwt:Secret' environment variable.");
|
|
}
|
|
|
|
if (jwtSecret.Length < 32)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"JWT secret must be at least 32 characters long. Current length: {jwtSecret.Length}. " +
|
|
"This is a security requirement for production environments.");
|
|
}
|
|
|
|
// Get issuer and audience configuration
|
|
var validIssuer = builder.Configuration["Authentication:Schemes:Bearer:ValidIssuer"];
|
|
var validAudiences = builder.Configuration.GetSection("Authentication:Schemes:Bearer:ValidAudiences")
|
|
.Get<string[]>() ?? Array.Empty<string>();
|
|
|
|
// Determine if validation should be enabled (enable in production, allow override via config)
|
|
var enableIssuerValidation = builder.Configuration.GetValue<bool>("Jwt:ValidateIssuer",
|
|
!builder.Environment.IsDevelopment());
|
|
var enableAudienceValidation = builder.Configuration.GetValue<bool>("Jwt:ValidateAudience",
|
|
!builder.Environment.IsDevelopment());
|
|
|
|
// Configure clock skew (tolerance for time differences between servers)
|
|
var clockSkewSeconds = builder.Configuration.GetValue<int>("Jwt:ClockSkewSeconds", 0);
|
|
var clockSkew = TimeSpan.FromSeconds(clockSkewSeconds);
|
|
|
|
builder.Services
|
|
.AddAuthentication(options =>
|
|
{
|
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
})
|
|
.AddJwtBearer(o =>
|
|
{
|
|
o.SaveToken = true;
|
|
o.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidIssuer = validIssuer,
|
|
ValidAudiences = validAudiences.Length > 0 ? validAudiences : null,
|
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
|
|
ValidateIssuerSigningKey = true,
|
|
ValidateIssuer = enableIssuerValidation && !string.IsNullOrWhiteSpace(validIssuer),
|
|
ValidateAudience = enableAudienceValidation && validAudiences.Length > 0,
|
|
ValidateLifetime = true, // Explicitly validate token expiration
|
|
ClockSkew = clockSkew, // Configure clock skew tolerance
|
|
RequireExpirationTime = true, // Ensure tokens have expiration
|
|
RequireSignedTokens = true // Ensure tokens are signed
|
|
};
|
|
o.Events = new JwtBearerEvents
|
|
{
|
|
OnMessageReceived = context =>
|
|
{
|
|
// Skip token extraction for anonymous endpoints to avoid validation errors
|
|
var path = context.Request.Path.Value?.ToLower();
|
|
if (path != null && (path.EndsWith("/create-token") || path.EndsWith("/authenticate")))
|
|
{
|
|
// Clear any token to prevent validation on anonymous endpoints
|
|
context.Token = null;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// Handle tokens sent without "Bearer " prefix for authenticated endpoints
|
|
// The standard middleware expects "Bearer <token>" but some clients send just the token
|
|
if (string.IsNullOrEmpty(context.Token))
|
|
{
|
|
var authHeader = context.Request.Headers["Authorization"].FirstOrDefault();
|
|
if (!string.IsNullOrEmpty(authHeader))
|
|
{
|
|
// If header doesn't start with "Bearer ", treat the entire value as the token
|
|
if (!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
context.Token = authHeader;
|
|
}
|
|
// Otherwise, let the default middleware extract it (it will strip "Bearer " automatically)
|
|
}
|
|
}
|
|
|
|
// If you want to get the token from a custom header or query string
|
|
// var accessToken = context.Request.Query["access_token"];
|
|
// if (!string.IsNullOrEmpty(accessToken) &&
|
|
// context.HttpContext.Request.Path.StartsWithSegments("/hub"))
|
|
// {
|
|
// context.Token = accessToken;
|
|
// }
|
|
return Task.CompletedTask;
|
|
},
|
|
OnAuthenticationFailed = context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices
|
|
.GetService<ILogger<Program>>();
|
|
|
|
// Check if the endpoint allows anonymous access
|
|
var endpoint = context.HttpContext.GetEndpoint();
|
|
var allowAnonymous = endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null;
|
|
|
|
// For anonymous endpoints with malformed tokens, skip authentication instead of failing
|
|
if (allowAnonymous && context.Exception is SecurityTokenMalformedException)
|
|
{
|
|
logger?.LogDebug("Skipping malformed token validation for anonymous endpoint: {Path}",
|
|
context.Request.Path);
|
|
context.NoResult(); // Skip authentication, don't fail
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (context.Exception is SecurityTokenExpiredException)
|
|
{
|
|
context.Response.Headers["Token-Expired"] = "true";
|
|
logger?.LogWarning("JWT token expired for request: {Path}",
|
|
context.Request.Path);
|
|
}
|
|
else
|
|
{
|
|
logger?.LogError(context.Exception,
|
|
"JWT authentication failed for request: {Path}",
|
|
context.Request.Path);
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
},
|
|
// --- IMPORTANT: Attach User to Context Here ---
|
|
OnTokenValidated = async context =>
|
|
{
|
|
var logger = context.HttpContext.RequestServices
|
|
.GetService<ILogger<Program>>();
|
|
|
|
try
|
|
{
|
|
var userService = context.HttpContext.RequestServices
|
|
.GetRequiredService<IUserService>();
|
|
|
|
// JWT token contains 'address' claim (not NameIdentifier)
|
|
var address = context.Principal.FindFirst("address")?.Value;
|
|
|
|
if (!string.IsNullOrEmpty(address))
|
|
{
|
|
// Fetch the full user object from your service
|
|
var user = await userService.GetUserByAddressAsync(address);
|
|
if (user != null)
|
|
{
|
|
// Attach the user object to HttpContext.Items
|
|
context.HttpContext.Items["User"] = user;
|
|
logger?.LogDebug("User {Address} authenticated successfully", address);
|
|
}
|
|
else
|
|
{
|
|
logger?.LogWarning(
|
|
"JWT token validated but user not found for address: {Address}",
|
|
address);
|
|
context.Fail("User not found");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
logger?.LogWarning("JWT token validated but 'address' claim not found");
|
|
context.Fail("Invalid token: missing address claim");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogError(ex,
|
|
"Error during JWT token validation - user lookup failed");
|
|
context.Fail("Authentication failed: user lookup error");
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
}
|
|
// --- END IMPORTANT ---
|
|
};
|
|
});
|
|
|
|
// Configure CORS from configuration (appsettings.json)
|
|
var allowedCorsOrigins = builder.Configuration
|
|
.GetSection("Cors:AllowedOrigins")
|
|
.Get<string[]>() ?? Array.Empty<string>();
|
|
|
|
// Configure CORS with improved security
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("CorsPolicy", policy =>
|
|
{
|
|
if (allowedCorsOrigins.Length > 0)
|
|
{
|
|
policy
|
|
.WithOrigins(allowedCorsOrigins)
|
|
.WithMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
|
.WithHeaders("Content-Type", "Authorization", "X-Requested-With", "X-Correlation-ID")
|
|
.WithExposedHeaders("Token-Expired", "X-Correlation-ID")
|
|
.AllowCredentials()
|
|
.SetPreflightMaxAge(TimeSpan.FromHours(24));
|
|
}
|
|
else
|
|
{
|
|
// Fallback for development if no origins configured
|
|
policy
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader()
|
|
.SetIsOriginAllowed(_ => true);
|
|
}
|
|
});
|
|
});
|
|
|
|
builder.Services.AddSignalR().AddJsonProtocol();
|
|
builder.Services.AddScoped<IJwtUtils, JwtUtils>();
|
|
|
|
builder.Services.RegisterApiDependencies(builder.Configuration);
|
|
|
|
// Orleans is always configured, but grains can be controlled
|
|
builder.Host.ConfigureOrleans(builder.Configuration, builder.Environment.IsProduction());
|
|
builder.Services.AddHostedServices();
|
|
var enableSwagger = builder.Configuration.GetValue<bool>("EnableSwagger", builder.Environment.IsDevelopment());
|
|
if (enableSwagger)
|
|
{
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddOpenApiDocument(document =>
|
|
{
|
|
document.AddSecurity("JWT", Enumerable.Empty<string>(), new OpenApiSecurityScheme
|
|
{
|
|
Type = OpenApiSecuritySchemeType.ApiKey,
|
|
Name = "Authorization",
|
|
In = OpenApiSecurityApiKeyLocation.Header,
|
|
Description = "Type into the textbox: Bearer {your JWT token}."
|
|
});
|
|
|
|
document.OperationProcessors.Add(
|
|
new AspNetCoreOperationSecurityScopeProcessor("JWT"));
|
|
});
|
|
builder.Services.AddSwaggerGen(options =>
|
|
{
|
|
options.SchemaFilter<EnumSchemaFilter>();
|
|
options.AddSecurityDefinition("Bearer,", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|
{
|
|
Description = "Please insert your JWT Token into field : Bearer {your_token}",
|
|
Name = "Authorization",
|
|
Type = SecuritySchemeType.Http,
|
|
In = ParameterLocation.Header,
|
|
Scheme = "Bearer",
|
|
BearerFormat = "JWT"
|
|
});
|
|
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
{
|
|
{
|
|
new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|
{
|
|
Reference = new OpenApiReference
|
|
{
|
|
Type = ReferenceType.SecurityScheme,
|
|
Id = "Bearer"
|
|
}
|
|
},
|
|
new string[] { }
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
builder.WebHost.SetupDiscordBot();
|
|
|
|
// App
|
|
var app = builder.Build();
|
|
|
|
app.UseSerilogRequestLogging();
|
|
|
|
// Rate Limiting - must be before authentication/authorization
|
|
app.UseIpRateLimiting();
|
|
|
|
// Security Headers Middleware - add early in pipeline
|
|
app.Use(async (context, next) =>
|
|
{
|
|
// Security headers for all responses
|
|
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
|
context.Response.Headers.Append("X-Frame-Options", "DENY");
|
|
context.Response.Headers.Append("X-XSS-Protection", "1; mode=block");
|
|
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
context.Response.Headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
|
|
|
|
// Content Security Policy - only for non-Swagger endpoints
|
|
if (!context.Request.Path.StartsWithSegments("/swagger") &&
|
|
!context.Request.Path.StartsWithSegments("/health") &&
|
|
!context.Request.Path.StartsWithSegments("/alive"))
|
|
{
|
|
context.Response.Headers.Append("Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;");
|
|
}
|
|
|
|
// Remove server header (optional - Kestrel can be configured separately)
|
|
context.Response.Headers.Remove("Server");
|
|
|
|
await next();
|
|
});
|
|
|
|
// HSTS - HTTP Strict Transport Security (production only)
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseHsts();
|
|
}
|
|
|
|
if (enableSwagger)
|
|
{
|
|
app.UseOpenApi();
|
|
app.UseSwaggerUI(c =>
|
|
{
|
|
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Managing API v1");
|
|
c.RoutePrefix = string.Empty;
|
|
});
|
|
}
|
|
|
|
app.UseCors("CorsPolicy");
|
|
|
|
// Add Sentry diagnostics middleware (now using shared version)
|
|
app.UseSentryDiagnostics();
|
|
|
|
// Using shared GlobalErrorHandlingMiddleware from core project
|
|
app.UseMiddleware<GlobalErrorHandlingMiddleware>();
|
|
|
|
app.UseHttpsRedirection();
|
|
app.UseRouting();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.UseEndpoints(endpoints =>
|
|
{
|
|
endpoints.MapControllers();
|
|
endpoints.MapHub<BotHub>("/bothub");
|
|
endpoints.MapHub<BacktestHub>("/backtesthub");
|
|
|
|
endpoints.MapHealthChecks("/health", new HealthCheckOptions
|
|
{
|
|
Predicate = r => !r.Tags.Contains("candles-detailed"),
|
|
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
|
});
|
|
|
|
endpoints.MapHealthChecks("/health-candles", new HealthCheckOptions
|
|
{
|
|
Predicate = r => r.Tags.Contains("candles-detailed"),
|
|
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
|
});
|
|
|
|
endpoints.MapHealthChecks("/alive", new HealthCheckOptions
|
|
{
|
|
Predicate = r => r.Tags.Contains("live"),
|
|
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
|
});
|
|
});
|
|
|
|
// Conditionally run the application based on deployment mode
|
|
var deploymentMode = builder.Configuration.GetValue<bool>("DeploymentMode", false);
|
|
|
|
if (!deploymentMode)
|
|
{
|
|
Console.WriteLine("Application starting in normal mode...");
|
|
app.Run();
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("Application configured for deployment mode - skipping app.Run()");
|
|
Console.WriteLine("All services have been configured and the application is ready for deployment.");
|
|
} |