fix: preview token
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 2s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m55s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 2m19s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 1s

This commit is contained in:
Ricardo Carneiro 2025-09-13 23:32:09 -03:00
parent 930ce8dab3
commit 04f406f6bc
6 changed files with 97 additions and 133 deletions

View File

@ -962,9 +962,10 @@ public class AdminController : Controller
if (pageItem == null || pageItem.UserId != user.Id)
return Json(new { success = false, message = "Página não encontrada" });
// Só renovar token para páginas "Creating"
if (pageItem.Status != ViewModels.PageStatus.Creating)
return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento" });
// Só renovar token para páginas "Creating" e "Rejected"
if (pageItem.Status != ViewModels.PageStatus.Creating &&
pageItem.Status != ViewModels.PageStatus.Rejected)
return Json(new { success = false, message = "Token só pode ser renovado para páginas em desenvolvimento ou rejeitadas" });
try
{
@ -977,7 +978,7 @@ public class AdminController : Controller
success = true,
previewToken = newToken,
previewUrl = previewUrl,
expiresAt = DateTime.UtcNow.AddHours(4).ToString("yyyy-MM-dd HH:mm:ss")
expiresAt = DateTime.UtcNow.AddMinutes(5).ToString("yyyy-MM-dd HH:mm:ss")
});
}
catch (Exception ex)

View File

@ -81,6 +81,10 @@ public class PageStatusMiddleware
return;
}
// LOG DETALHADO ANTES da comparação
_logger.LogInformation("Token comparison for page {PageId} - Provided: {ProvidedToken}, DB Token: {DbToken}, DB Expiry: {DbExpiry}",
page.Id, previewToken, page.PreviewToken, page.PreviewTokenExpiry);
if (previewToken != page.PreviewToken)
{
_logger.LogInformation($"User id: {userId} - Page {category}/{slug} preview token mismatch - provided: {previewToken}, expected: {page.PreviewToken}");

View File

@ -1,121 +0,0 @@
using BCards.Web.Services;
using Microsoft.Extensions.Caching.Memory;
namespace BCards.Web.Middleware;
public class PreviewTokenMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly ILogger<PreviewTokenMiddleware> _logger;
public PreviewTokenMiddleware(RequestDelegate next, IMemoryCache cache, ILogger<PreviewTokenMiddleware> logger)
{
_next = next;
_cache = cache;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value;
var query = context.Request.Query;
// Verificar se é uma requisição de preview
if (path != null && path.StartsWith("/page/") && query.ContainsKey("preview"))
{
var previewToken = query["preview"].FirstOrDefault();
if (!string.IsNullOrEmpty(previewToken))
{
var result = await HandlePreviewRequest(context, previewToken);
if (!result)
{
context.Response.StatusCode = 404;
await context.Response.WriteAsync("Preview não encontrado ou expirado.");
return;
}
}
}
await _next(context);
}
private async Task<bool> HandlePreviewRequest(HttpContext context, string previewToken)
{
try
{
// Verificar rate limiting por IP
var clientIp = GetClientIpAddress(context);
var rateLimitKey = $"preview_rate_limit_{clientIp}";
if (_cache.TryGetValue(rateLimitKey, out int requestCount))
{
if (requestCount >= 10) // Máximo 10 requisições por minuto por IP
{
_logger.LogWarning("Rate limit exceeded for IP {IP} on preview token {Token}", clientIp, previewToken);
return false;
}
_cache.Set(rateLimitKey, requestCount + 1, TimeSpan.FromMinutes(1));
}
else
{
_cache.Set(rateLimitKey, 1, TimeSpan.FromMinutes(1));
}
// Verificar se o token é válido
var moderationService = context.RequestServices.GetService<IModerationService>();
if (moderationService == null)
{
_logger.LogError("ModerationService not found in DI container");
return false;
}
var page = await moderationService.GetPageByPreviewTokenAsync(previewToken);
if (page == null)
{
_logger.LogInformation("Invalid or expired preview token: {Token}", previewToken);
return false;
}
// Incrementar contador de visualizações
var incrementResult = await moderationService.IncrementPreviewViewAsync(page.Id);
if (!incrementResult)
{
_logger.LogWarning("Preview view limit exceeded for page {PageId}", page.Id);
return false;
}
// Adicionar informações do preview ao contexto
context.Items["IsPreview"] = true;
context.Items["PreviewPageId"] = page.Id;
context.Items["PreviewToken"] = previewToken;
_logger.LogInformation("Valid preview request for page {PageId} with token {Token}", page.Id, previewToken);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling preview request with token {Token}", previewToken);
return false;
}
}
private string GetClientIpAddress(HttpContext context)
{
// Verificar cabeçalhos de proxy
var xForwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(xForwardedFor))
{
return xForwardedFor.Split(',')[0].Trim();
}
var xRealIp = context.Request.Headers["X-Real-IP"].FirstOrDefault();
if (!string.IsNullOrEmpty(xRealIp))
{
return xRealIp;
}
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
}

View File

@ -30,7 +30,9 @@ public class UserPageRepository : IUserPageRepository
public async Task<UserPage?> GetByIdAsync(string id)
{
return await _pages.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
// Forçar leitura do primary para consultas críticas (preview tokens)
return await _pages.WithReadPreference(ReadPreference.Primary)
.Find(x => x.Id == id && x.IsActive).FirstOrDefaultAsync();
}
public async Task<UserPage?> GetBySlugAsync(string category, string slug)
@ -48,7 +50,9 @@ public class UserPageRepository : IUserPageRepository
Collation = collation
};
var cursor = await _pages.FindAsync(filter, findOptions);
// Forçar leitura do primary para consultas críticas (preview tokens)
var cursor = await _pages.WithReadPreference(ReadPreference.Primary)
.FindAsync(filter, findOptions);
return await cursor.FirstOrDefaultAsync();
}

View File

@ -27,15 +27,29 @@ public class ModerationService : IModerationService
public async Task<string> GeneratePreviewTokenAsync(string pageId)
{
var token = Guid.NewGuid().ToString("N")[..16];
var expiry = DateTime.UtcNow.AddHours(4); // Token válido por 4 horas
var expiry = DateTime.UtcNow.AddMinutes(5); // Token válido por 5 minutos
// LOG ANTES da busca
_logger.LogInformation("Generating token for page {PageId} - searching page in DB", pageId);
var page = await _userPageRepository.GetByIdAsync(pageId);
// LOG TOKEN ANTERIOR
_logger.LogInformation("Page {PageId} - Old token: {OldToken}, New token: {NewToken}",
pageId, page.PreviewToken, token);
page.PreviewToken = token;
page.PreviewTokenExpiry = expiry;
page.PreviewViewCount = 0;
// LOG ANTES do update
_logger.LogInformation("Updating page {PageId} with new token {Token}", pageId, token);
await _userPageRepository.UpdateAsync(page);
_logger.LogInformation("Generated preview token for page {PageId}", pageId);
// LOG APÓS update
_logger.LogInformation("Successfully updated page {PageId} with token {Token}", pageId, token);
return token;
}

View File

@ -374,6 +374,52 @@
}
});
// Sistema de auto-refresh para tokens de preview
let refreshInterval;
let activePreviewPages = new Map(); // pageId -> {category, slug, windowRef}
// Iniciar auto-refresh a cada 4 minutos
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(async () => {
if (activePreviewPages.size > 0) {
console.log(`Auto-refreshing ${activePreviewPages.size} active preview tokens...`);
for (const [pageId, pageData] of activePreviewPages) {
if (!pageData.windowRef.closed) {
await refreshPageToken(pageId, pageData);
} else {
// Aba fechada, remover do tracking
activePreviewPages.delete(pageId);
}
}
}
}, 4 * 60 * 1000); // 4 minutos
}
async function refreshPageToken(pageId, pageData) {
try {
// Usar o endpoint específico para refresh ao invés de generate
const response = await fetch(`/Admin/RefreshPreviewToken/${pageId}`, {
method: 'POST',
headers: { 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]').value }
});
const result = await response.json();
if (result.success) {
// Atualizar URL da aba existente
const newUrl = `${window.location.origin}/page/${pageData.category}/${pageData.slug}?preview=${result.previewToken}`;
pageData.windowRef.location.href = newUrl;
console.log(`Token refreshed for page ${pageId}: ${result.previewToken}`);
} else {
console.warn(`Failed to refresh token for page ${pageId}: ${result.message}`);
}
} catch (error) {
console.error(`Failed to refresh token for page ${pageId}:`, error);
}
}
// Iniciar auto-refresh quando a página carrega
startAutoRefresh();
// Funções existentes (submitForModeration, openPreview, etc.)
async function openPreview(pageId) {
const button = event.target.closest('button');
@ -388,7 +434,23 @@
});
const result = await response.json();
if (result.success) {
window.open(`${window.location.origin}/page/${category}/${slug}?preview=${result.previewToken}`, '_blank');
// Delay de 500ms para garantir commit no MongoDB
await new Promise(resolve => setTimeout(resolve, 500));
// Abrir nova aba e adicionar ao tracking
const previewWindow = window.open(
`${window.location.origin}/page/${category}/${slug}?preview=${result.previewToken}`,
`preview_${pageId}` // Nome único para a aba
);
// Adicionar ao tracking para auto-refresh
activePreviewPages.set(pageId, {
category: category,
slug: slug,
windowRef: previewWindow
});
console.log(`Page ${pageId} added to active preview tracking`);
} else {
showToast(result.message || 'Erro ao gerar preview', 'error');
}