feat: ratelimit
This commit is contained in:
parent
9cbf3cc75c
commit
03e6c74ada
28
OnlyOneAccessTemplate/Attributes/RateLimitAttribute.cs
Normal file
28
OnlyOneAccessTemplate/Attributes/RateLimitAttribute.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
29
OnlyOneAccessTemplate/Models/ConverterConfig.cs
Normal file
29
OnlyOneAccessTemplate/Models/ConverterConfig.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
OnlyOneAccessTemplate/Models/RateLimits/RateLimitEntry.cs
Normal file
11
OnlyOneAccessTemplate/Models/RateLimits/RateLimitEntry.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,9 @@ namespace OnlyOneAccessTemplate.Models
|
||||
|
||||
public DateTime CreatedAt { 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
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
</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="MongoDB.Driver" Version="3.4.0" />
|
||||
<PackageReference Include="Polly" Version="8.4.0" />
|
||||
|
||||
@ -70,6 +70,22 @@ builder.Services.AddSingleton<IMongoClient>(sp =>
|
||||
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 =>
|
||||
{
|
||||
var client = sp.GetRequiredService<IMongoClient>();
|
||||
@ -138,6 +154,37 @@ app.UseSession();
|
||||
app.UseResponseCompression();
|
||||
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();
|
||||
|
||||
// Custom routing for multilingual support
|
||||
@ -153,9 +200,18 @@ app.MapControllerRoute(
|
||||
|
||||
// Rotas específicas por idioma
|
||||
app.MapControllerRoute(
|
||||
name: "multilingual",
|
||||
pattern: "{language:regex(en|es)}/{controller=Home}/{action=Index}/{id?}");
|
||||
name: "converter_localized_specific",
|
||||
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(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
|
||||
45
OnlyOneAccessTemplate/Services/ConfigurationService.cs
Normal file
45
OnlyOneAccessTemplate/Services/ConfigurationService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -112,6 +112,21 @@ namespace OnlyOneAccessTemplate.Services
|
||||
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
|
||||
{
|
||||
Responsive,
|
||||
|
||||
96
OnlyOneAccessTemplate/Services/RateLimitService.cs
Normal file
96
OnlyOneAccessTemplate/Services/RateLimitService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
26
OnlyOneAccessTemplate/Views/BaseViewModel.cs
Normal file
26
OnlyOneAccessTemplate/Views/BaseViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -51,5 +51,9 @@
|
||||
"RectanglePre": "SEU_SLOT_ID_2"
|
||||
}
|
||||
},
|
||||
"RateLimiting": {
|
||||
"MaxRequestsPerHour": 50,
|
||||
"CaptchaThreshold": 20
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@ -52,5 +52,9 @@
|
||||
"RectanglePre": "SEU_SLOT_ID_2"
|
||||
}
|
||||
},
|
||||
"RateLimiting": {
|
||||
"MaxRequestsPerHour": 50,
|
||||
"CaptchaThreshold": 20
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user