feat: ratelimit

This commit is contained in:
Ricardo Carneiro 2025-06-05 11:52:03 -03:00
parent 9cbf3cc75c
commit 03e6c74ada
12 changed files with 321 additions and 2 deletions

View File

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Mvc.Filters;
using OnlyOneAccessTemplate.Services;
namespace OnlyOneAccessTemplate.Attributes
{
public class RateLimitAttribute : ActionFilterAttribute
{
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var rateLimitService = context.HttpContext.RequestServices.GetRequiredService<IRateLimitService>();
var ipAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(ipAddress))
{
await rateLimitService.RecordRequestAsync(ipAddress);
if (await rateLimitService.ShouldShowCaptchaAsync(ipAddress))
{
var captcha = await rateLimitService.GenerateCaptchaAsync();
context.HttpContext.Items["ShowCaptcha"] = true;
context.HttpContext.Items["CaptchaChallenge"] = captcha.Challenge;
}
}
await next();
}
}
}

View File

@ -0,0 +1,29 @@
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Bson;
namespace OnlyOneAccessTemplate.Models
{
public class ConverterConfig
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Route { get; set; } = string.Empty; // ex: "converter-pdf-para-word"
public string Title { get; set; } = string.Empty; // SEO title
public string Description { get; set; } = string.Empty; // SEO description
public string Keywords { get; set; } = string.Empty; // SEO keywords
public bool IsActive { get; set; } = true;
public string InputType { get; set; } = string.Empty; // "pdf", "image", "text"
public string OutputType { get; set; } = string.Empty; // "word", "jpg", "uppercase"
public string Icon { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty; // "pt", "en", "es"
// Configurações específicas do conversor
public Dictionary<string, object> Settings { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,11 @@
namespace OnlyOneAccessTemplate.Models.RateLimits
{
public class RateLimitEntry
{
public string IpAddress { get; set; } = string.Empty;
public int RequestCount { get; set; }
public DateTime LastRequest { get; set; }
public bool RequiresCaptcha { get; set; }
public DateTime? CaptchaExpiry { get; set; }
}
}

View File

@ -31,6 +31,9 @@ namespace OnlyOneAccessTemplate.Models
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public string Domain { get; set; } = string.Empty;
public string TwitterHandle { get; set; } = string.Empty;
public Dictionary<string, string> CustomMeta { get; set; } = new();
} }
public class HomePageContent public class HomePageContent

View File

@ -19,6 +19,8 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Localization" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-preview.4.25258.110" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" /> <PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<PackageReference Include="Polly" Version="8.4.0" /> <PackageReference Include="Polly" Version="8.4.0" />

View File

@ -70,6 +70,22 @@ builder.Services.AddSingleton<IMongoClient>(sp =>
return new MongoClient(connectionString); return new MongoClient(connectionString);
}); });
// Rate Limiting Service
builder.Services.AddScoped<IRateLimitService, RateLimitService>();
// Configuration Service (para substituir o ISiteConfigurationService se necessário)
builder.Services.AddScoped<IConfigurationService, ConfigurationService>();
// Configuração de localização para SEO multi-idioma
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[] { "pt-BR", "en-US", "es-ES" };
options.SetDefaultCulture(supportedCultures[0])
.AddSupportedCultures(supportedCultures)
.AddSupportedUICultures(supportedCultures);
});
builder.Services.AddScoped(sp => builder.Services.AddScoped(sp =>
{ {
var client = sp.GetRequiredService<IMongoClient>(); var client = sp.GetRequiredService<IMongoClient>();
@ -138,6 +154,37 @@ app.UseSession();
app.UseResponseCompression(); app.UseResponseCompression();
app.UseResponseCaching(); app.UseResponseCaching();
app.UseRequestLocalization();
// Middleware para detectar configuração por domínio
app.Use(async (context, next) =>
{
var host = context.Request.Host.Host;
var configService = context.RequestServices.GetRequiredService<IConfigurationService>();
var config = await configService.GetConfigurationAsync(host);
context.Items["SiteConfig"] = config;
// Se não encontrou config por domínio, usar fallback baseado no idioma da URL
if (config == null)
{
var pathSegments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
var language = "pt"; // default
if (pathSegments?.Length > 0 && new[] { "en", "es", "pt" }.Contains(pathSegments[0]))
{
language = pathSegments[0];
}
// Usar seu serviço existente como fallback
var siteConfigService = context.RequestServices.GetRequiredService<ISiteConfigurationService>();
var fallbackConfig = await siteConfigService.GetConfigurationAsync(language);
context.Items["SiteConfig"] = fallbackConfig;
}
await next();
});
app.UseRouting(); app.UseRouting();
// Custom routing for multilingual support // Custom routing for multilingual support
@ -153,9 +200,18 @@ app.MapControllerRoute(
// Rotas específicas por idioma // Rotas específicas por idioma
app.MapControllerRoute( app.MapControllerRoute(
name: "multilingual", name: "converter_localized_specific",
pattern: "{language:regex(en|es)}/{controller=Home}/{action=Index}/{id?}"); pattern: "{language:regex(^(pt|en|es)$)}/{converter}",
defaults: new { controller = "Converter", action = "Index" });
// Rota para home localizada
app.MapControllerRoute(
name: "home_localized",
pattern: "{language:regex(^(pt|en|es)$)}",
defaults: new { controller = "Home", action = "Index" });
// Manter suas rotas de API existentes (não mexer)
// Rota padrão para português (fallback)
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"); pattern: "{controller=Home}/{action=Index}/{id?}");

View File

@ -0,0 +1,45 @@
using Microsoft.Extensions.Caching.Memory;
using MongoDB.Driver;
using OnlyOneAccessTemplate.Models;
namespace OnlyOneAccessTemplate.Services
{
public class ConfigurationService : IConfigurationService
{
private readonly IMongoDatabase _database;
private readonly IMemoryCache _cache;
public ConfigurationService(IMongoDatabase database, IMemoryCache cache)
{
_database = database;
_cache = cache;
}
public async Task<SiteConfiguration> GetConfigurationAsync(string domain)
{
var cacheKey = $"config_{domain}";
if (_cache.TryGetValue(cacheKey, out SiteConfiguration cachedConfig))
return cachedConfig;
var collection = _database.GetCollection<SiteConfiguration>("SiteConfigurations");
var config = await collection.Find(x => x.Domain == domain).FirstOrDefaultAsync();
if (config != null)
_cache.Set(cacheKey, config, TimeSpan.FromMinutes(30));
return config;
}
public async Task<List<ConverterConfig>> GetConvertersAsync(string language)
{
var collection = _database.GetCollection<ConverterConfig>("Converters");
return await collection.Find(x => x.IsActive).ToListAsync();
}
public async Task<ConverterConfig> GetConverterAsync(string id, string language)
{
var collection = _database.GetCollection<ConverterConfig>("Converters");
return await collection.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
}
}
}

View File

@ -112,6 +112,21 @@ namespace OnlyOneAccessTemplate.Services
string GenerateAdHtml(string position, string adSlot, AdSize size = AdSize.Responsive); string GenerateAdHtml(string position, string adSlot, AdSize size = AdSize.Responsive);
} }
public interface IConfigurationService
{
Task<SiteConfiguration> GetConfigurationAsync(string domain);
Task<List<ConverterConfig>> GetConvertersAsync(string language);
Task<ConverterConfig> GetConverterAsync(string id, string language);
}
public interface IRateLimitService
{
Task<bool> ShouldShowCaptchaAsync(string ipAddress);
Task RecordRequestAsync(string ipAddress);
Task<(bool IsValid, string Challenge)> GenerateCaptchaAsync();
Task<bool> ValidateCaptchaAsync(string challenge, string response);
}
public enum AdSize public enum AdSize
{ {
Responsive, Responsive,

View File

@ -0,0 +1,96 @@
using Microsoft.Extensions.Caching.Memory;
using OnlyOneAccessTemplate.Models.RateLimits;
namespace OnlyOneAccessTemplate.Services
{
public class RateLimitService : IRateLimitService
{
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
private const int MAX_REQUESTS_PER_HOUR = 50;
private const int CAPTCHA_THRESHOLD = 20;
public RateLimitService(IMemoryCache cache, IConfiguration configuration)
{
_cache = cache;
_configuration = configuration;
}
public async Task<bool> ShouldShowCaptchaAsync(string ipAddress)
{
var key = $"rate_limit_{ipAddress}";
if (_cache.TryGetValue(key, out RateLimitEntry entry))
{
// Resetar contador se passou mais de 1 hora
if (DateTime.UtcNow.Subtract(entry.LastRequest).TotalHours > 1)
{
entry.RequestCount = 0;
}
return entry.RequestCount >= CAPTCHA_THRESHOLD;
}
return false;
}
public async Task RecordRequestAsync(string ipAddress)
{
var key = $"rate_limit_{ipAddress}";
if (_cache.TryGetValue(key, out RateLimitEntry entry))
{
entry.RequestCount++;
entry.LastRequest = DateTime.UtcNow;
}
else
{
entry = new RateLimitEntry
{
IpAddress = ipAddress,
RequestCount = 1,
LastRequest = DateTime.UtcNow
};
}
_cache.Set(key, entry, TimeSpan.FromHours(2));
}
public async Task<(bool IsValid, string Challenge)> GenerateCaptchaAsync()
{
// Captcha matemático simples
var random = new Random();
var num1 = random.Next(1, 10);
var num2 = random.Next(1, 10);
var operation = random.Next(0, 2) == 0 ? "+" : "-";
var challenge = $"{num1} {operation} {num2}";
var answer = operation == "+" ? num1 + num2 : num1 - num2;
var challengeKey = Guid.NewGuid().ToString();
_cache.Set($"captcha_{challengeKey}", answer, TimeSpan.FromMinutes(10));
return (true, $"{challenge}|{challengeKey}");
}
public async Task<bool> ValidateCaptchaAsync(string challenge, string response)
{
if (string.IsNullOrEmpty(challenge) || string.IsNullOrEmpty(response))
return false;
var parts = challenge.Split('|');
if (parts.Length != 2) return false;
var challengeKey = parts[1];
var cacheKey = $"captcha_{challengeKey}";
if (_cache.TryGetValue(cacheKey, out int expectedAnswer))
{
_cache.Remove(cacheKey);
if (int.TryParse(response, out int userAnswer))
{
return userAnswer == expectedAnswer;
}
}
return false;
}
}
}

View File

@ -0,0 +1,26 @@
using OnlyOneAccessTemplate.Models;
namespace OnlyOneAccessTemplate.Views
{
public class BaseViewModel
{
public SiteConfiguration SiteConfig { get; set; }
public string CurrentLanguage { get; set; }
public bool ShowCaptcha { get; set; }
public string CaptchaChallenge { get; set; }
}
public class HomeViewModel : BaseViewModel
{
public List<ConverterConfig> AvailableConverters { get; set; } = new();
}
public class ConverterViewModel : BaseViewModel
{
public ConverterConfig Converter { get; set; }
public string InputContent { get; set; } = string.Empty;
public string OutputContent { get; set; } = string.Empty;
public bool HasError { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
}
}

View File

@ -51,5 +51,9 @@
"RectanglePre": "SEU_SLOT_ID_2" "RectanglePre": "SEU_SLOT_ID_2"
} }
}, },
"RateLimiting": {
"MaxRequestsPerHour": 50,
"CaptchaThreshold": 20
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@ -52,5 +52,9 @@
"RectanglePre": "SEU_SLOT_ID_2" "RectanglePre": "SEU_SLOT_ID_2"
} }
}, },
"RateLimiting": {
"MaxRequestsPerHour": 50,
"CaptchaThreshold": 20
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }