From 70ba07bb644889f2429eb5309aeb5ef2fdf3e0bd Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Tue, 24 Jun 2025 23:58:38 -0300 Subject: [PATCH 01/42] fix: busca da pagina e ajuste de icones --- .../Repositories/UserPageRepository.cs | 2 +- src/BCards.Web/Views/UserPage/Display.cshtml | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/BCards.Web/Repositories/UserPageRepository.cs b/src/BCards.Web/Repositories/UserPageRepository.cs index 5954196..e03c7f3 100644 --- a/src/BCards.Web/Repositories/UserPageRepository.cs +++ b/src/BCards.Web/Repositories/UserPageRepository.cs @@ -31,7 +31,7 @@ public class UserPageRepository : IUserPageRepository public async Task GetBySlugAsync(string category, string slug) { - return await _pages.Find(x => x.Category == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync(); + return await _pages.Find(x => x.Category.ToLower() == category.ToLower() && x.Slug == slug && x.IsActive).FirstOrDefaultAsync(); } public async Task GetByUserIdAsync(string userId) diff --git a/src/BCards.Web/Views/UserPage/Display.cshtml b/src/BCards.Web/Views/UserPage/Display.cshtml index 7c4d296..eae3687 100644 --- a/src/BCards.Web/Views/UserPage/Display.cshtml +++ b/src/BCards.Web/Views/UserPage/Display.cshtml @@ -3,7 +3,7 @@ var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; var category = ViewBag.Category as BCards.Web.Models.Category; var isPreview = ViewBag.IsPreview as bool? ?? false; - + ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}"; Layout = isPreview ? "_Layout" : "_UserPageLayout"; } @@ -11,9 +11,16 @@ @if (!isPreview) { @section Styles { - + + + } +} +else +{ + @section Styles { + } } @@ -59,7 +66,7 @@ onclick="recordClick('@Model.Id', @i)"> @if (!string.IsNullOrEmpty(link.Icon)) { - @link.Icon + }
From 27ae8b606ed7418fd6b0e0843aa99659c565643a Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Wed, 25 Jun 2025 19:30:19 -0300 Subject: [PATCH 02/42] =?UTF-8?q?feat:=20+login=20ms=20que=20permite=20con?= =?UTF-8?q?tas=20corporativas=20ou=20n=C3=A3o.=20+Links=20para=20produtos?= =?UTF-8?q?=20de=20afiliados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- src/BCards.Web/BCards.Web.csproj | 1 + src/BCards.Web/Controllers/AdminController.cs | 24 +- src/BCards.Web/Controllers/AuthController.cs | 18 + .../Controllers/ProductController.cs | 131 ++++++ .../Controllers/UserPageController.cs | 17 +- src/BCards.Web/Models/LinkItem.cs | 25 ++ src/BCards.Web/Models/OpenGraphCache.cs | 55 +++ src/BCards.Web/Models/PlanLimitations.cs | 16 + src/BCards.Web/Models/PlanType.cs | 29 ++ src/BCards.Web/Program.cs | 19 + src/BCards.Web/Services/IOpenGraphService.cs | 10 + src/BCards.Web/Services/IThemeService.cs | 1 + src/BCards.Web/Services/OpenGraphService.cs | 299 +++++++++++++ src/BCards.Web/Services/ThemeService.cs | 123 ++++++ src/BCards.Web/Utils/AllowedDomains.cs | 69 +++ .../ViewModels/ManagePageViewModel.cs | 16 + src/BCards.Web/Views/Admin/ManagePage.cshtml | 416 +++++++++++++++--- .../Views/Shared/_ThemeStyles.cshtml | 135 +++++- src/BCards.Web/Views/UserPage/Display.cshtml | 95 ++-- 20 files changed, 1404 insertions(+), 98 deletions(-) create mode 100644 src/BCards.Web/Controllers/ProductController.cs create mode 100644 src/BCards.Web/Models/OpenGraphCache.cs create mode 100644 src/BCards.Web/Services/IOpenGraphService.cs create mode 100644 src/BCards.Web/Services/OpenGraphService.cs create mode 100644 src/BCards.Web/Utils/AllowedDomains.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 48b5eab..3dd86bd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,8 @@ "Bash(rg:*)", "Bash(pkill:*)", "Bash(sudo rm:*)", - "Bash(rm:*)" + "Bash(rm:*)", + "Bash(curl:*)" ] }, "enableAllProjectMcpServers": false diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj index f5427fc..65fd4a4 100644 --- a/src/BCards.Web/BCards.Web.csproj +++ b/src/BCards.Web/BCards.Web.csproj @@ -17,6 +17,7 @@ + diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index 77f547a..5c82e6c 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -493,7 +493,13 @@ public class AdminController : Controller Description = l.Description, Icon = l.Icon, Order = l.Order, - IsActive = l.IsActive + IsActive = l.IsActive, + Type = l.Type, + ProductTitle = l.ProductTitle, + ProductImage = l.ProductImage, + ProductPrice = l.ProductPrice, + ProductDescription = l.ProductDescription, + ProductDataCachedAt = l.ProductDataCachedAt }).ToList() ?? new List(), AvailableCategories = categories, AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), @@ -529,7 +535,13 @@ public class AdminController : Controller Description = l.Description, Icon = l.Icon, IsActive = l.IsActive, - Order = index + Order = index, + Type = l.Type, + ProductTitle = l.ProductTitle, + ProductImage = l.ProductImage, + ProductPrice = l.ProductPrice, + ProductDescription = l.ProductDescription, + ProductDataCachedAt = l.ProductDataCachedAt })); } @@ -612,7 +624,13 @@ public class AdminController : Controller Description = l.Description, Icon = l.Icon, IsActive = l.IsActive, - Order = index + Order = index, + Type = l.Type, + ProductTitle = l.ProductTitle, + ProductImage = l.ProductImage, + ProductPrice = l.ProductPrice, + ProductDescription = l.ProductDescription, + ProductDataCachedAt = l.ProductDataCachedAt })); } diff --git a/src/BCards.Web/Controllers/AuthController.cs b/src/BCards.Web/Controllers/AuthController.cs index 7c4a359..7a48beb 100644 --- a/src/BCards.Web/Controllers/AuthController.cs +++ b/src/BCards.Web/Controllers/AuthController.cs @@ -110,8 +110,26 @@ public class AuthController : Controller [Authorize] public async Task Logout() { + // Identifica qual provedor foi usado + var authResult = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + var loginProvider = authResult.Principal?.FindFirst("LoginProvider")?.Value; + + // Faz logout local primeiro await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + TempData["Success"] = "Logout realizado com sucesso"; + + // Se foi Microsoft, faz logout completo no provedor + if (loginProvider == "Microsoft") + { + return SignOut(MicrosoftAccountDefaults.AuthenticationScheme); + } + // Se foi Google, faz logout completo no provedor + else if (loginProvider == "Google") + { + return SignOut(GoogleDefaults.AuthenticationScheme); + } + return RedirectToAction("Index", "Home"); } diff --git a/src/BCards.Web/Controllers/ProductController.cs b/src/BCards.Web/Controllers/ProductController.cs new file mode 100644 index 0000000..830c9c5 --- /dev/null +++ b/src/BCards.Web/Controllers/ProductController.cs @@ -0,0 +1,131 @@ +using BCards.Web.Models; +using BCards.Web.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace BCards.Web.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class ProductController : ControllerBase +{ + private readonly IOpenGraphService _openGraphService; + private readonly ILogger _logger; + + public ProductController( + IOpenGraphService openGraphService, + ILogger logger) + { + _openGraphService = openGraphService; + _logger = logger; + } + + [HttpPost("extract")] + public async Task ExtractProduct([FromBody] ExtractProductRequest request) + { + try + { + if (string.IsNullOrWhiteSpace(request.Url)) + { + return BadRequest(new { success = false, message = "URL é obrigatória." }); + } + + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri)) + { + return BadRequest(new { success = false, message = "URL inválida." }); + } + + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new { success = false, message = "Usuário não autenticado." }); + } + + // Verificar rate limiting antes de tentar extrair + var isRateLimited = await _openGraphService.IsRateLimitedAsync(userId); + if (isRateLimited) + { + return this.TooManyRequests(new { + success = false, + message = "Aguarde 1 minuto antes de extrair dados de outro produto." + }); + } + + var ogData = await _openGraphService.ExtractDataAsync(request.Url, userId); + + if (!ogData.IsValid) + { + return BadRequest(new { + success = false, + message = string.IsNullOrEmpty(ogData.ErrorMessage) + ? "Não foi possível extrair dados desta página." + : ogData.ErrorMessage + }); + } + + return Ok(new { + success = true, + title = ogData.Title, + description = ogData.Description, + image = ogData.Image, + price = ogData.Price, + currency = ogData.Currency + }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Operação inválida na extração de produto para usuário {UserId}", + User.FindFirst(ClaimTypes.NameIdentifier)?.Value); + return BadRequest(new { success = false, message = ex.Message }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro interno na extração de produto para usuário {UserId}", + User.FindFirst(ClaimTypes.NameIdentifier)?.Value); + return StatusCode(500, new { + success = false, + message = "Erro interno do servidor. Tente novamente em alguns instantes." + }); + } + } + + [HttpGet("cache/{urlHash}")] + public Task GetCachedData(string urlHash) + { + try + { + // Por segurança, vamos reconstruir a URL a partir do hash (se necessário) + // Por agora, apenas retornamos erro se não encontrado + return Task.FromResult(NotFound(new { success = false, message = "Cache não encontrado." })); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao buscar cache para hash {UrlHash}", urlHash); + return Task.FromResult(StatusCode(500, new { success = false, message = "Erro interno do servidor." })); + } + } +} + +public class ExtractProductRequest +{ + public string Url { get; set; } = string.Empty; +} + +// Custom result for 429 Too Many Requests +public class TooManyRequestsResult : ObjectResult +{ + public TooManyRequestsResult(object value) : base(value) + { + StatusCode = 429; + } +} + +public static class ControllerBaseExtensions +{ + public static TooManyRequestsResult TooManyRequests(this ControllerBase controller, object value) + { + return new TooManyRequestsResult(value); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/UserPageController.cs b/src/BCards.Web/Controllers/UserPageController.cs index 81cb418..0f17640 100644 --- a/src/BCards.Web/Controllers/UserPageController.cs +++ b/src/BCards.Web/Controllers/UserPageController.cs @@ -9,15 +9,18 @@ public class UserPageController : Controller private readonly IUserPageService _userPageService; private readonly ICategoryService _categoryService; private readonly ISeoService _seoService; + private readonly IThemeService _themeService; public UserPageController( IUserPageService userPageService, ICategoryService categoryService, - ISeoService seoService) + ISeoService seoService, + IThemeService themeService) { _userPageService = userPageService; _categoryService = categoryService; _seoService = seoService; + _themeService = themeService; } //[Route("{category}/{slug}")] @@ -32,6 +35,12 @@ public class UserPageController : Controller if (categoryObj == null) return NotFound(); + // Ensure theme is loaded - critical fix for theme display issue + if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor)) + { + userPage.Theme = _themeService.GetDefaultTheme(); + } + // Generate SEO settings var seoSettings = _seoService.GenerateSeoSettings(userPage, categoryObj); @@ -65,6 +74,12 @@ public class UserPageController : Controller if (categoryObj == null) return NotFound(); + // Ensure theme is loaded for preview too + if (userPage.Theme?.Id == null || string.IsNullOrEmpty(userPage.Theme.PrimaryColor)) + { + userPage.Theme = _themeService.GetDefaultTheme(); + } + ViewBag.Category = categoryObj; ViewBag.IsPreview = true; diff --git a/src/BCards.Web/Models/LinkItem.cs b/src/BCards.Web/Models/LinkItem.cs index 5dd05ce..0ae91a8 100644 --- a/src/BCards.Web/Models/LinkItem.cs +++ b/src/BCards.Web/Models/LinkItem.cs @@ -2,6 +2,12 @@ using MongoDB.Bson.Serialization.Attributes; namespace BCards.Web.Models; +public enum LinkType +{ + Normal = 0, // Link comum + Product = 1 // Link de produto com preview +} + public class LinkItem { [BsonElement("title")] @@ -27,4 +33,23 @@ public class LinkItem [BsonElement("createdAt")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + // Campos para Link de Produto + [BsonElement("type")] + public LinkType Type { get; set; } = LinkType.Normal; + + [BsonElement("productTitle")] + public string ProductTitle { get; set; } = string.Empty; + + [BsonElement("productImage")] + public string ProductImage { get; set; } = string.Empty; + + [BsonElement("productPrice")] + public string ProductPrice { get; set; } = string.Empty; + + [BsonElement("productDescription")] + public string ProductDescription { get; set; } = string.Empty; + + [BsonElement("productDataCachedAt")] + public DateTime? ProductDataCachedAt { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Models/OpenGraphCache.cs b/src/BCards.Web/Models/OpenGraphCache.cs new file mode 100644 index 0000000..3931cab --- /dev/null +++ b/src/BCards.Web/Models/OpenGraphCache.cs @@ -0,0 +1,55 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class OpenGraphCache +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("url")] + public string Url { get; set; } = string.Empty; + + [BsonElement("urlHash")] + public string UrlHash { get; set; } = string.Empty; + + [BsonElement("title")] + public string Title { get; set; } = string.Empty; + + [BsonElement("description")] + public string Description { get; set; } = string.Empty; + + [BsonElement("image")] + public string Image { get; set; } = string.Empty; + + [BsonElement("price")] + public string Price { get; set; } = string.Empty; + + [BsonElement("currency")] + public string Currency { get; set; } = "BRL"; + + [BsonElement("isValid")] + public bool IsValid { get; set; } + + [BsonElement("errorMessage")] + public string ErrorMessage { get; set; } = string.Empty; + + [BsonElement("cachedAt")] + public DateTime CachedAt { get; set; } = DateTime.UtcNow; + + [BsonElement("expiresAt")] + public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(24); +} + +public class OpenGraphData +{ + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public string Price { get; set; } = string.Empty; + public string Currency { get; set; } = "BRL"; + public bool IsValid { get; set; } + public string ErrorMessage { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanLimitations.cs b/src/BCards.Web/Models/PlanLimitations.cs index f2d0585..bdf5d78 100644 --- a/src/BCards.Web/Models/PlanLimitations.cs +++ b/src/BCards.Web/Models/PlanLimitations.cs @@ -24,4 +24,20 @@ public class PlanLimitations [BsonElement("planType")] public string PlanType { get; set; } = "free"; + + // Novos campos para Links de Produto + [BsonElement("maxProductLinks")] + public int MaxProductLinks { get; set; } = 0; + + [BsonElement("maxOGExtractionsPerDay")] + public int MaxOGExtractionsPerDay { get; set; } = 0; + + [BsonElement("allowProductLinks")] + public bool AllowProductLinks { get; set; } = false; + + [BsonElement("ogExtractionsUsedToday")] + public int OGExtractionsUsedToday { get; set; } = 0; + + [BsonElement("lastExtractionDate")] + public DateTime? LastExtractionDate { get; set; } } \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanType.cs b/src/BCards.Web/Models/PlanType.cs index e6db8e9..03b2438 100644 --- a/src/BCards.Web/Models/PlanType.cs +++ b/src/BCards.Web/Models/PlanType.cs @@ -91,4 +91,33 @@ public static class PlanTypeExtensions { return planType == PlanType.Trial ? 7 : 0; } + + public static int GetMaxProductLinks(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 1, // 1 link de produto para trial + PlanType.Basic => 3, // 3 links de produto + PlanType.Professional => 8, // DECOY - mais caro para poucos benefícios + PlanType.Premium => int.MaxValue, // Ilimitado + _ => 0 + }; + } + + public static int GetMaxOGExtractionsPerDay(this PlanType planType) + { + return planType switch + { + PlanType.Trial => 2, // 2 extrações por dia no trial + PlanType.Basic => 5, // 5 extrações por dia + PlanType.Professional => 15, // 15 extrações por dia + PlanType.Premium => int.MaxValue, // Ilimitado + _ => 0 + }; + } + + public static bool AllowsProductLinks(this PlanType planType) + { + return GetMaxProductLinks(planType) > 0; + } } \ No newline at end of file diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index 5c55c74..da90e8d 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.Options; using MongoDB.Driver; using System.Globalization; +using Stripe; +using Microsoft.AspNetCore.Authentication.OAuth; var builder = WebApplication.CreateBuilder(args); @@ -69,6 +71,19 @@ builder.Services.AddAuthentication(options => var msAuth = builder.Configuration.GetSection("Authentication:Microsoft"); options.ClientId = msAuth["ClientId"] ?? ""; options.ClientSecret = msAuth["ClientSecret"] ?? ""; + + // Força seleção de conta a cada login + 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 => + { + context.Response.Redirect(context.RedirectUri + "&prompt=select_account"); + return Task.CompletedTask; + } + }; }); // Localization @@ -99,6 +114,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Add HttpClient for OpenGraphService +builder.Services.AddHttpClient(); // Background Services builder.Services.AddHostedService(); diff --git a/src/BCards.Web/Services/IOpenGraphService.cs b/src/BCards.Web/Services/IOpenGraphService.cs new file mode 100644 index 0000000..8a05748 --- /dev/null +++ b/src/BCards.Web/Services/IOpenGraphService.cs @@ -0,0 +1,10 @@ +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface IOpenGraphService +{ + Task ExtractDataAsync(string url, string userId); + Task IsRateLimitedAsync(string userId); + Task GetCachedDataAsync(string url); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IThemeService.cs b/src/BCards.Web/Services/IThemeService.cs index bed15e8..e07f08d 100644 --- a/src/BCards.Web/Services/IThemeService.cs +++ b/src/BCards.Web/Services/IThemeService.cs @@ -8,6 +8,7 @@ public interface IThemeService Task GetThemeByIdAsync(string themeId); Task GetThemeByNameAsync(string themeName); Task GenerateCustomCssAsync(PageTheme theme); + Task GenerateThemeCSSAsync(PageTheme theme, UserPage page); Task InitializeDefaultThemesAsync(); PageTheme GetDefaultTheme(); } \ No newline at end of file diff --git a/src/BCards.Web/Services/OpenGraphService.cs b/src/BCards.Web/Services/OpenGraphService.cs new file mode 100644 index 0000000..9451ec3 --- /dev/null +++ b/src/BCards.Web/Services/OpenGraphService.cs @@ -0,0 +1,299 @@ +using BCards.Web.Models; +using BCards.Web.Utils; +using Microsoft.Extensions.Caching.Memory; +using MongoDB.Driver; +using HtmlAgilityPack; +using System.Text.RegularExpressions; +using System.Security.Cryptography; +using System.Text; + +namespace BCards.Web.Services; + +public class OpenGraphService : IOpenGraphService +{ + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly IMongoCollection _ogCache; + + public OpenGraphService( + IMemoryCache cache, + ILogger logger, + HttpClient httpClient, + IMongoDatabase database) + { + _cache = cache; + _logger = logger; + _httpClient = httpClient; + _ogCache = database.GetCollection("openGraphCache"); + + // Configure HttpClient + _httpClient.DefaultRequestHeaders.Clear(); + //_httpClient.DefaultRequestHeaders.Add("User-Agent", + // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); + _httpClient.DefaultRequestHeaders.Add("User-Agent", + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"); + _httpClient.Timeout = TimeSpan.FromSeconds(10); + } + + public async Task ExtractDataAsync(string url, string userId) + { + // 1. Validar domínio + if (!AllowedDomains.IsAllowed(url)) + { + _logger.LogWarning("Tentativa de extração de domínio não permitido: {Url} pelo usuário {UserId}", url, userId); + throw new InvalidOperationException("Domínio não permitido. Use apenas e-commerces conhecidos e seguros."); + } + + // 2. Verificar rate limit (1 request por minuto por usuário) + var rateLimitKey = $"og_rate_{userId}"; + if (_cache.TryGetValue(rateLimitKey, out _)) + { + _logger.LogWarning("Rate limit excedido para usuário {UserId}", userId); + throw new InvalidOperationException("Aguarde 1 minuto antes de extrair dados de outro produto."); + } + + // 3. Verificar cache no MongoDB + var urlHash = GenerateUrlHash(url); + var cachedData = await GetCachedDataAsync(url); + + if (cachedData != null && cachedData.ExpiresAt > DateTime.UtcNow) + { + _logger.LogInformation("Retornando dados do cache MongoDB para URL: {Url}", url); + return new OpenGraphData + { + Title = cachedData.Title, + Description = cachedData.Description, + Image = cachedData.Image, + Price = cachedData.Price, + Currency = cachedData.Currency, + IsValid = cachedData.IsValid, + ErrorMessage = cachedData.ErrorMessage + }; + } + + // 4. Extrair dados da URL + var extractedData = await ExtractFromUrlAsync(url); + + // 5. Salvar no cache MongoDB + await SaveToCacheAsync(url, urlHash, extractedData); + + // 6. Aplicar rate limit (1 minuto) + _cache.Set(rateLimitKey, true, TimeSpan.FromMinutes(1)); + + _logger.LogInformation("Dados extraídos com sucesso para URL: {Url}", url); + return extractedData; + } + + public Task IsRateLimitedAsync(string userId) + { + var rateLimitKey = $"og_rate_{userId}"; + return Task.FromResult(_cache.TryGetValue(rateLimitKey, out _)); + } + + public async Task GetCachedDataAsync(string url) + { + var urlHash = GenerateUrlHash(url); + return await _ogCache + .Find(x => x.UrlHash == urlHash && x.ExpiresAt > DateTime.UtcNow) + .FirstOrDefaultAsync(); + } + + private async Task ExtractFromUrlAsync(string url) + { + try + { + _logger.LogInformation("Iniciando extração de dados para URL: {Url}", url); + + var response = await _httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + var html = await response.Content.ReadAsStringAsync(); + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + var title = GetMetaContent(doc, "og:title", "title") ?? GetTitleFromHTML(doc); + var description = GetMetaContent(doc, "og:description", "description"); + var image = GetMetaContent(doc, "og:image"); + var price = GetMetaContent(doc, "og:price:amount") ?? ExtractPriceFromHTML(html, doc); + var currency = GetMetaContent(doc, "og:price:currency") ?? "BRL"; + + // Limpar e validar dados + title = CleanText(title); + description = CleanText(description); + price = CleanPrice(price); + image = ValidateImageUrl(image, url); + + var isValid = !string.IsNullOrEmpty(title); + + return new OpenGraphData + { + Title = title, + Description = description, + Image = image, + Price = price, + Currency = currency, + IsValid = isValid + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Falha ao extrair dados de {Url}", url); + return new OpenGraphData + { + IsValid = false, + ErrorMessage = $"Erro ao processar a página: {ex.Message}" + }; + } + } + + private string? GetMetaContent(HtmlDocument doc, params string[] properties) + { + foreach (var property in properties) + { + var meta = doc.DocumentNode + .SelectSingleNode($"//meta[@property='{property}' or @name='{property}' or @itemprop='{property}']"); + + var content = meta?.GetAttributeValue("content", null); + if (!string.IsNullOrWhiteSpace(content)) + return content; + } + return null; + } + + private string? GetTitleFromHTML(HtmlDocument doc) + { + var titleNode = doc.DocumentNode.SelectSingleNode("//title"); + return titleNode?.InnerText?.Trim(); + } + + private string? ExtractPriceFromHTML(string html, HtmlDocument doc) + { + // Regex patterns para encontrar preços em diferentes formatos + var pricePatterns = new[] + { + @"R\$\s*[\d\.,]+", + @"BRL\s*[\d\.,]+", + @"[\$]\s*[\d\.,]+", + @"price[^>]*>([^<]*[\d\.,]+[^<]*)<", + @"valor[^>]*>([^<]*[\d\.,]+[^<]*)<", + @"preço[^>]*>([^<]*[\d\.,]+[^<]*)<" + }; + + foreach (var pattern in pricePatterns) + { + var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase); + if (match.Success) + { + return match.Value; + } + } + + // Tentar encontrar por seletores específicos + var priceSelectors = new[] + { + ".price", ".valor", ".preco", "[data-price]", ".price-current", + ".price-value", ".product-price", ".sale-price" + }; + + foreach (var selector in priceSelectors) + { + var priceNode = doc.DocumentNode.SelectSingleNode($"//*[contains(@class, '{selector.Replace(".", "")}')]"); + if (priceNode != null) + { + var priceText = priceNode.InnerText?.Trim(); + if (Regex.IsMatch(priceText ?? "", @"[\d\.,]+")) + { + return priceText; + } + } + } + + return null; + } + + private string CleanText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + return Regex.Replace(text.Trim(), @"\s+", " "); + } + + private string CleanPrice(string? price) + { + if (string.IsNullOrWhiteSpace(price)) + return string.Empty; + + // Limpar e formatar preço + var cleanPrice = Regex.Replace(price, @"[^\d\.,R\$]", " ").Trim(); + return Regex.Replace(cleanPrice, @"\s+", " "); + } + + private string ValidateImageUrl(string? imageUrl, string baseUrl) + { + if (string.IsNullOrWhiteSpace(imageUrl)) + return string.Empty; + + try + { + // Se for URL relativa, converter para absoluta + if (imageUrl.StartsWith("/")) + { + var baseUri = new Uri(baseUrl); + return $"{baseUri.Scheme}://{baseUri.Host}{imageUrl}"; + } + + // Validar se é uma URL válida + if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri)) + { + return uri.ToString(); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Erro ao validar URL da imagem: {ImageUrl}", imageUrl); + } + + return string.Empty; + } + + private string GenerateUrlHash(string url) + { + using var sha256 = SHA256.Create(); + var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(url.ToLowerInvariant())); + return Convert.ToBase64String(hashBytes); + } + + private async Task SaveToCacheAsync(string url, string urlHash, OpenGraphData data) + { + try + { + var cacheItem = new OpenGraphCache + { + Url = url, + UrlHash = urlHash, + Title = data.Title, + Description = data.Description, + Image = data.Image, + Price = data.Price, + Currency = data.Currency, + IsValid = data.IsValid, + ErrorMessage = data.ErrorMessage, + CachedAt = DateTime.UtcNow, + ExpiresAt = data.IsValid ? DateTime.UtcNow.AddHours(24) : DateTime.UtcNow.AddHours(1) + }; + + // Upsert no MongoDB + await _ogCache.ReplaceOneAsync( + x => x.UrlHash == urlHash, + cacheItem, + new ReplaceOptions { IsUpsert = true } + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Erro ao salvar cache para URL: {Url}", url); + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/ThemeService.cs b/src/BCards.Web/Services/ThemeService.cs index fd11302..e889504 100644 --- a/src/BCards.Web/Services/ThemeService.cs +++ b/src/BCards.Web/Services/ThemeService.cs @@ -1,5 +1,6 @@ using BCards.Web.Models; using MongoDB.Driver; +using System.Text; namespace BCards.Web.Services; @@ -133,6 +134,128 @@ public class ThemeService : IThemeService return Task.FromResult(css); } + public async Task GenerateThemeCSSAsync(PageTheme theme, UserPage page) + { + var css = new StringBuilder(); + + // CSS base com variáveis do tema + css.AppendLine($":root {{"); + css.AppendLine($" --primary-color: {theme.PrimaryColor};"); + css.AppendLine($" --secondary-color: {theme.SecondaryColor};"); + css.AppendLine($" --background-color: {theme.BackgroundColor};"); + css.AppendLine($" --text-color: {theme.TextColor};"); + css.AppendLine($"}}"); + + // CSS específico por tema + switch (theme.Name?.ToLower()) + { + case "minimalista": + css.AppendLine(GetMinimalistCSS()); + break; + case "corporativo": + css.AppendLine(GetCorporateCSS()); + break; + case "dark mode": + css.AppendLine(GetDarkCSS()); + break; + case "natureza": + css.AppendLine(GetNatureCSS()); + break; + case "vibrante": + css.AppendLine(GetVibrantCSS()); + break; + default: + css.AppendLine(await GenerateCustomCssAsync(theme)); + break; + } + + return css.ToString(); + } + + private string GetMinimalistCSS() => @" + .profile-card { + background: white; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + border-radius: 12px; + } + .link-button { + background: var(--primary-color); + border-radius: 8px; + } + "; + + private string GetCorporateCSS() => @" + .user-page { + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); + } + .profile-card { + background: white; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border: 1px solid #e2e8f0; + } + .link-button { + background: var(--primary-color); + border-radius: 6px; + font-weight: 600; + } + "; + + private string GetDarkCSS() => @" + .user-page { + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); + } + .profile-card { + background: rgba(255,255,255,0.1); + backdrop-filter: blur(15px); + border: 1px solid rgba(255,255,255,0.2); + color: #f9fafb; + } + .link-button { + background: var(--primary-color); + box-shadow: 0 4px 15px rgba(0,0,0,0.3); + } + .profile-name, .profile-bio { + color: #f9fafb; + } + "; + + private string GetNatureCSS() => @" + .user-page { + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); + background-image: url('data:image/svg+xml,'); + } + .profile-card { + background: rgba(255,255,255,0.9); + backdrop-filter: blur(10px); + border: 1px solid rgba(34, 197, 94, 0.2); + } + .link-button { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + border-radius: 25px; + } + "; + + private string GetVibrantCSS() => @" + .user-page { + background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 50%, #fecaca 100%); + } + .profile-card { + background: rgba(255,255,255,0.95); + box-shadow: 0 8px 32px rgba(220, 38, 38, 0.2); + border: 2px solid rgba(220, 38, 38, 0.1); + } + .link-button { + background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); + border-radius: 30px; + transform: perspective(1000px) rotateX(0deg); + transition: all 0.3s ease; + } + .link-button:hover { + transform: perspective(1000px) rotateX(-5deg) translateY(-5px); + box-shadow: 0 15px 30px rgba(220, 38, 38, 0.3); + } + "; + public async Task InitializeDefaultThemesAsync() { var existingThemes = await _themes.Find(x => x.IsActive).ToListAsync(); diff --git a/src/BCards.Web/Utils/AllowedDomains.cs b/src/BCards.Web/Utils/AllowedDomains.cs new file mode 100644 index 0000000..17cec15 --- /dev/null +++ b/src/BCards.Web/Utils/AllowedDomains.cs @@ -0,0 +1,69 @@ +namespace BCards.Web.Utils; + +public static class AllowedDomains +{ + public static readonly HashSet EcommerceWhitelist = new(StringComparer.OrdinalIgnoreCase) + { + // Principais E-commerces Brasileiros + "mercadolivre.com.br", "mercadolibre.com", + "amazon.com.br", "amazon.com", + "magazineluiza.com.br", "magalu.com.br", + "americanas.com", "submarino.com.br", + "extra.com.br", "pontofrio.com.br", + "casasbahia.com.br", "casas.com.br", + "shopee.com.br", "shopee.com", "s.shopee.com.br", + "aliexpress.com", "aliexpress.us", + "netshoes.com.br", "centauro.com.br", + "dafiti.com.br", "kanui.com.br", + "fastshop.com.br", "kabum.com.br", + "pichau.com.br", "terabyteshop.com.br", + + // Marketplaces Internacionais Seguros + "ebay.com", "etsy.com", "walmart.com", + "target.com", "bestbuy.com", + + // E-commerces de Moda + "zara.com", "hm.com", "gap.com", + "uniqlo.com", "forever21.com", + + // Livrarias e Educação + "saraiva.com.br", "livrariacultura.com.br", + "estantevirtual.com.br", + + // Casa e Decoração + "mobly.com.br", "tok-stok.com.br", + "westwing.com.br", "madeiramadeira.com.br" + }; + + public static bool IsAllowed(string url) + { + try + { + var uri = new Uri(url); + var domain = uri.Host.ToLowerInvariant(); + + // Remove "www." se existir + if (domain.StartsWith("www.")) + domain = domain.Substring(4); + + return EcommerceWhitelist.Contains(domain); + } + catch + { + return false; + } + } + + public static string GetDomainFromUrl(string url) + { + try + { + var uri = new Uri(url); + return uri.Host.ToLowerInvariant().Replace("www.", ""); + } + catch + { + return string.Empty; + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index 5710c3e..f3e460c 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -63,6 +63,22 @@ public class ManageLinkViewModel public string Icon { get; set; } = string.Empty; public int Order { get; set; } = 0; public bool IsActive { get; set; } = true; + + // Campos para Links de Produto + public LinkType Type { get; set; } = LinkType.Normal; + + [StringLength(100, ErrorMessage = "Título do produto deve ter no máximo 100 caracteres")] + public string ProductTitle { get; set; } = string.Empty; + + public string ProductImage { get; set; } = string.Empty; + + [StringLength(50, ErrorMessage = "Preço deve ter no máximo 50 caracteres")] + public string ProductPrice { get; set; } = string.Empty; + + [StringLength(200, ErrorMessage = "Descrição do produto deve ter no máximo 200 caracteres")] + public string ProductDescription { get; set; } = string.Empty; + + public DateTime? ProductDataCachedAt { get; set; } } public class DashboardViewModel diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml index 690f6f3..f2f1140 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -355,41 +355,126 @@