diff --git a/OnlyOneAccessTemplate/Attributes/RateLimitAttribute.cs b/OnlyOneAccessTemplate/Attributes/RateLimitAttribute.cs new file mode 100644 index 0000000..fb73da4 --- /dev/null +++ b/OnlyOneAccessTemplate/Attributes/RateLimitAttribute.cs @@ -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(); + 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(); + } + } +} diff --git a/OnlyOneAccessTemplate/Models/ConverterConfig.cs b/OnlyOneAccessTemplate/Models/ConverterConfig.cs new file mode 100644 index 0000000..7d49236 --- /dev/null +++ b/OnlyOneAccessTemplate/Models/ConverterConfig.cs @@ -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 Settings { get; set; } = new(); + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/OnlyOneAccessTemplate/Models/RateLimits/RateLimitEntry.cs b/OnlyOneAccessTemplate/Models/RateLimits/RateLimitEntry.cs new file mode 100644 index 0000000..304e2c5 --- /dev/null +++ b/OnlyOneAccessTemplate/Models/RateLimits/RateLimitEntry.cs @@ -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; } + } +} diff --git a/OnlyOneAccessTemplate/Models/SiteConfiguration.cs b/OnlyOneAccessTemplate/Models/SiteConfiguration.cs index 0815a8a..28555ac 100644 --- a/OnlyOneAccessTemplate/Models/SiteConfiguration.cs +++ b/OnlyOneAccessTemplate/Models/SiteConfiguration.cs @@ -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 CustomMeta { get; set; } = new(); } public class HomePageContent diff --git a/OnlyOneAccessTemplate/OnlyOneAccessTemplate.csproj b/OnlyOneAccessTemplate/OnlyOneAccessTemplate.csproj index c0f9ca8..b731710 100644 --- a/OnlyOneAccessTemplate/OnlyOneAccessTemplate.csproj +++ b/OnlyOneAccessTemplate/OnlyOneAccessTemplate.csproj @@ -19,6 +19,8 @@ + + diff --git a/OnlyOneAccessTemplate/Program.cs b/OnlyOneAccessTemplate/Program.cs index 5c7234f..de84a2d 100644 --- a/OnlyOneAccessTemplate/Program.cs +++ b/OnlyOneAccessTemplate/Program.cs @@ -70,6 +70,22 @@ builder.Services.AddSingleton(sp => return new MongoClient(connectionString); }); + +// Rate Limiting Service +builder.Services.AddScoped(); + +// Configuration Service (para substituir o ISiteConfigurationService se necessário) +builder.Services.AddScoped(); + +// Configuração de localização para SEO multi-idioma +builder.Services.Configure(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(); @@ -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(); + 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(); + 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?}"); diff --git a/OnlyOneAccessTemplate/Services/ConfigurationService.cs b/OnlyOneAccessTemplate/Services/ConfigurationService.cs new file mode 100644 index 0000000..0302a99 --- /dev/null +++ b/OnlyOneAccessTemplate/Services/ConfigurationService.cs @@ -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 GetConfigurationAsync(string domain) + { + var cacheKey = $"config_{domain}"; + if (_cache.TryGetValue(cacheKey, out SiteConfiguration cachedConfig)) + return cachedConfig; + + var collection = _database.GetCollection("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> GetConvertersAsync(string language) + { + var collection = _database.GetCollection("Converters"); + return await collection.Find(x => x.IsActive).ToListAsync(); + } + + public async Task GetConverterAsync(string id, string language) + { + var collection = _database.GetCollection("Converters"); + return await collection.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync(); + } + } +} diff --git a/OnlyOneAccessTemplate/Services/Interfaces.cs b/OnlyOneAccessTemplate/Services/Interfaces.cs index 287ef9f..8be6867 100644 --- a/OnlyOneAccessTemplate/Services/Interfaces.cs +++ b/OnlyOneAccessTemplate/Services/Interfaces.cs @@ -112,6 +112,21 @@ namespace OnlyOneAccessTemplate.Services string GenerateAdHtml(string position, string adSlot, AdSize size = AdSize.Responsive); } + public interface IConfigurationService + { + Task GetConfigurationAsync(string domain); + Task> GetConvertersAsync(string language); + Task GetConverterAsync(string id, string language); + } + + public interface IRateLimitService + { + Task ShouldShowCaptchaAsync(string ipAddress); + Task RecordRequestAsync(string ipAddress); + Task<(bool IsValid, string Challenge)> GenerateCaptchaAsync(); + Task ValidateCaptchaAsync(string challenge, string response); + } + public enum AdSize { Responsive, diff --git a/OnlyOneAccessTemplate/Services/RateLimitService.cs b/OnlyOneAccessTemplate/Services/RateLimitService.cs new file mode 100644 index 0000000..88ed0e7 --- /dev/null +++ b/OnlyOneAccessTemplate/Services/RateLimitService.cs @@ -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 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 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; + } + } +} diff --git a/OnlyOneAccessTemplate/Views/BaseViewModel.cs b/OnlyOneAccessTemplate/Views/BaseViewModel.cs new file mode 100644 index 0000000..a9fb94a --- /dev/null +++ b/OnlyOneAccessTemplate/Views/BaseViewModel.cs @@ -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 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; + } +} diff --git a/OnlyOneAccessTemplate/appsettings.Production.json b/OnlyOneAccessTemplate/appsettings.Production.json index 95e2dc8..c37d4e1 100644 --- a/OnlyOneAccessTemplate/appsettings.Production.json +++ b/OnlyOneAccessTemplate/appsettings.Production.json @@ -51,5 +51,9 @@ "RectanglePre": "SEU_SLOT_ID_2" } }, + "RateLimiting": { + "MaxRequestsPerHour": 50, + "CaptchaThreshold": 20 + }, "AllowedHosts": "*" } diff --git a/OnlyOneAccessTemplate/appsettings.json b/OnlyOneAccessTemplate/appsettings.json index b0e4cf5..ae1a2c7 100644 --- a/OnlyOneAccessTemplate/appsettings.json +++ b/OnlyOneAccessTemplate/appsettings.json @@ -52,5 +52,9 @@ "RectanglePre": "SEU_SLOT_ID_2" } }, + "RateLimiting": { + "MaxRequestsPerHour": 50, + "CaptchaThreshold": 20 + }, "AllowedHosts": "*" }