| | | 1 | | using System.Text; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | |
| | | 4 | | namespace OzonOrdersWeb.Middleware; |
| | | 5 | | |
| | | 6 | | public class UserHistoryMiddleware |
| | | 7 | | { |
| | | 8 | | private readonly RequestDelegate _next; |
| | | 9 | | |
| | 0 | 10 | | public UserHistoryMiddleware(RequestDelegate next) |
| | | 11 | | { |
| | 0 | 12 | | _next = next; |
| | 0 | 13 | | } |
| | | 14 | | |
| | | 15 | | public async Task InvokeAsync(HttpContext context) |
| | | 16 | | { |
| | | 17 | | // Пропускаем статические файлы, API и другие специальные пути |
| | 0 | 18 | | var path = context.Request.Path.Value?.ToLower() ?? ""; |
| | 0 | 19 | | if (path.StartsWith("/lib/") || |
| | 0 | 20 | | path.StartsWith("/css/") || |
| | 0 | 21 | | path.StartsWith("/js/") || |
| | 0 | 22 | | path.StartsWith("/images/") || |
| | 0 | 23 | | path.StartsWith("/favicon") || |
| | 0 | 24 | | path.StartsWith("/_framework/") || |
| | 0 | 25 | | path.StartsWith("/api/") || |
| | 0 | 26 | | path.StartsWith("/hangfire") || |
| | 0 | 27 | | path.StartsWith("/notificationhub") || |
| | 0 | 28 | | path.StartsWith("/orderhistoryhub") || |
| | 0 | 29 | | path.StartsWith("/priceupdatehub") || |
| | 0 | 30 | | path.StartsWith("/.well-known/") || |
| | 0 | 31 | | path.StartsWith("/devtools/") || |
| | 0 | 32 | | path.StartsWith("/json") || |
| | 0 | 33 | | context.Request.Headers["Accept"].ToString().Contains("application/json") || |
| | 0 | 34 | | context.Request.Headers["Accept"].ToString().Contains("application/xml")) |
| | | 35 | | { |
| | 0 | 36 | | await _next(context); |
| | 0 | 37 | | return; |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | // Перехватываем только HTML ответы |
| | 0 | 41 | | var originalBodyStream = context.Response.Body; |
| | 0 | 42 | | using var responseBody = new MemoryStream(); |
| | 0 | 43 | | context.Response.Body = responseBody; |
| | | 44 | | |
| | | 45 | | try |
| | | 46 | | { |
| | 0 | 47 | | await _next(context); |
| | | 48 | | |
| | | 49 | | // Проверяем, что это HTML ответ и статус успешный |
| | 0 | 50 | | var contentType = context.Response.ContentType?.ToLower() ?? ""; |
| | 0 | 51 | | var isHtml = contentType.Contains("text/html") && |
| | 0 | 52 | | context.Response.StatusCode >= 200 && |
| | 0 | 53 | | context.Response.StatusCode < 300; |
| | | 54 | | |
| | 0 | 55 | | string? responseText = null; |
| | | 56 | | |
| | 0 | 57 | | if (isHtml) |
| | | 58 | | { |
| | 0 | 59 | | responseBody.Seek(0, SeekOrigin.Begin); |
| | 0 | 60 | | var bodyText = await new StreamReader(responseBody, Encoding.UTF8).ReadToEndAsync(); |
| | | 61 | | |
| | | 62 | | // Проверяем, что это действительно HTML (содержит теги) |
| | 0 | 63 | | if (bodyText.Contains("<html", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 64 | | bodyText.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 65 | | bodyText.Contains("<body", StringComparison.OrdinalIgnoreCase)) |
| | | 66 | | { |
| | 0 | 67 | | var historyScript = "<script src=\"/js/observability/client-telemetry.js\"></script>\n" |
| | 0 | 68 | | + GetHistoryTrackingScript(context); |
| | | 69 | | |
| | 0 | 70 | | var bodyCloseIndex = bodyText.LastIndexOf("</body>", StringComparison.OrdinalIgnoreCase); |
| | 0 | 71 | | if (bodyCloseIndex >= 0) |
| | | 72 | | { |
| | 0 | 73 | | responseText = bodyText.Insert(bodyCloseIndex, historyScript + "\n"); |
| | | 74 | | } |
| | | 75 | | else |
| | | 76 | | { |
| | 0 | 77 | | var htmlCloseIndex = bodyText.LastIndexOf("</html>", StringComparison.OrdinalIgnoreCase); |
| | 0 | 78 | | responseText = htmlCloseIndex >= 0 |
| | 0 | 79 | | ? bodyText.Insert(htmlCloseIndex, historyScript + "\n") |
| | 0 | 80 | | : bodyText + historyScript; |
| | | 81 | | } |
| | | 82 | | } |
| | | 83 | | } |
| | | 84 | | |
| | | 85 | | // Возвращаем оригинальный поток ДО записи (иначе при ошибке обработчик |
| | | 86 | | // попытается писать в уже освобождённый MemoryStream — ObjectDisposedException). |
| | 0 | 87 | | context.Response.Body = originalBodyStream; |
| | | 88 | | |
| | 0 | 89 | | if (responseText != null) |
| | | 90 | | { |
| | 0 | 91 | | var responseBytes = Encoding.UTF8.GetBytes(responseText); |
| | 0 | 92 | | context.Response.ContentLength = responseBytes.Length; |
| | 0 | 93 | | await originalBodyStream.WriteAsync(responseBytes); |
| | | 94 | | } |
| | | 95 | | else |
| | | 96 | | { |
| | 0 | 97 | | responseBody.Seek(0, SeekOrigin.Begin); |
| | 0 | 98 | | await responseBody.CopyToAsync(originalBodyStream); |
| | | 99 | | } |
| | 0 | 100 | | } |
| | | 101 | | finally |
| | | 102 | | { |
| | | 103 | | // Гарантированно восстанавливаем исходный поток ответа: иначе вышестоящий |
| | | 104 | | // ExceptionHandler/StatusCodePages при повторном выполнении пишет в закрытый MemoryStream. |
| | 0 | 105 | | context.Response.Body = originalBodyStream; |
| | | 106 | | } |
| | 0 | 107 | | } |
| | | 108 | | |
| | | 109 | | private string GetHistoryTrackingScript(HttpContext context) |
| | | 110 | | { |
| | 0 | 111 | | var currentPath = (context.Request.Path + context.Request.QueryString).ToString(); |
| | 0 | 112 | | var currentTitle = context.Request.Path.Value?.Split('/').LastOrDefault() ?? "Страница"; |
| | | 113 | | |
| | | 114 | | // Экранируем специальные символы для JavaScript |
| | 0 | 115 | | var escapedPath = currentPath.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\""); |
| | 0 | 116 | | var escapedTitle = currentTitle.Replace("\\", "\\\\").Replace("'", "\\'").Replace("\"", "\\\""); |
| | | 117 | | |
| | 0 | 118 | | return $@" |
| | 0 | 119 | | <script> |
| | 0 | 120 | | (function() {{ |
| | 0 | 121 | | 'use strict'; |
| | 0 | 122 | | |
| | 0 | 123 | | // Функция для сохранения истории в local storage |
| | 0 | 124 | | function savePageToHistory(path, title) {{ |
| | 0 | 125 | | try {{ |
| | 0 | 126 | | const historyKey = 'userPageHistory'; |
| | 0 | 127 | | const maxHistoryItems = 50; // Максимальное количество записей |
| | 0 | 128 | | |
| | 0 | 129 | | let history = JSON.parse(localStorage.getItem(historyKey) || '[]'); |
| | 0 | 130 | | |
| | 0 | 131 | | // Добавляем текущую страницу |
| | 0 | 132 | | const historyItem = {{ |
| | 0 | 133 | | path: path, |
| | 0 | 134 | | title: title || document.title || path, |
| | 0 | 135 | | timestamp: new Date().toISOString(), |
| | 0 | 136 | | displayTime: new Date().toLocaleString('ru-RU', {{ |
| | 0 | 137 | | day: '2-digit', |
| | 0 | 138 | | month: '2-digit', |
| | 0 | 139 | | year: 'numeric', |
| | 0 | 140 | | hour: '2-digit', |
| | 0 | 141 | | minute: '2-digit', |
| | 0 | 142 | | second: '2-digit' |
| | 0 | 143 | | }}) |
| | 0 | 144 | | }}; |
| | 0 | 145 | | |
| | 0 | 146 | | // Удаляем дубликаты (если та же страница уже есть) |
| | 0 | 147 | | history = history.filter(item => item.path !== path); |
| | 0 | 148 | | |
| | 0 | 149 | | // Добавляем новую запись в начало |
| | 0 | 150 | | history.unshift(historyItem); |
| | 0 | 151 | | |
| | 0 | 152 | | // Ограничиваем количество записей |
| | 0 | 153 | | if (history.length > maxHistoryItems) {{ |
| | 0 | 154 | | history = history.slice(0, maxHistoryItems); |
| | 0 | 155 | | }} |
| | 0 | 156 | | |
| | 0 | 157 | | // Сохраняем в local storage |
| | 0 | 158 | | localStorage.setItem(historyKey, JSON.stringify(history)); |
| | 0 | 159 | | }} catch (e) {{ |
| | 0 | 160 | | console.error('Ошибка при сохранении истории:', e); |
| | 0 | 161 | | }} |
| | 0 | 162 | | }} |
| | 0 | 163 | | |
| | 0 | 164 | | // Сохраняем текущую страницу при загрузке |
| | 0 | 165 | | if (document.readyState === 'loading') {{ |
| | 0 | 166 | | document.addEventListener('DOMContentLoaded', function() {{ |
| | 0 | 167 | | savePageToHistory('{escapedPath}', '{escapedTitle}'); |
| | 0 | 168 | | }}); |
| | 0 | 169 | | }} else {{ |
| | 0 | 170 | | savePageToHistory('{escapedPath}', '{escapedTitle}'); |
| | 0 | 171 | | }} |
| | 0 | 172 | | |
| | 0 | 173 | | // Отслеживаем переходы по ссылкам |
| | 0 | 174 | | document.addEventListener('click', function(e) {{ |
| | 0 | 175 | | const link = e.target.closest('a[href]'); |
| | 0 | 176 | | if (link && link.href && !link.href.startsWith('javascript:') && !link.href.startsWith('#')) {{ |
| | 0 | 177 | | // Небольшая задержка для сохранения перед переходом |
| | 0 | 178 | | setTimeout(function() {{ |
| | 0 | 179 | | const href = link.getAttribute('href'); |
| | 0 | 180 | | if (href && !href.startsWith('http') || href.startsWith(window.location.origin)) {{ |
| | 0 | 181 | | const fullPath = href.startsWith('http') ? href : (window.location.origin + href); |
| | 0 | 182 | | savePageToHistory(fullPath, link.textContent.trim() || link.title || href); |
| | 0 | 183 | | }} |
| | 0 | 184 | | }}, 100); |
| | 0 | 185 | | }} |
| | 0 | 186 | | }}); |
| | 0 | 187 | | |
| | 0 | 188 | | // Отслеживаем изменения истории браузера (назад/вперед) |
| | 0 | 189 | | window.addEventListener('popstate', function() {{ |
| | 0 | 190 | | setTimeout(function() {{ |
| | 0 | 191 | | savePageToHistory(window.location.pathname + window.location.search, document.title); |
| | 0 | 192 | | }}, 100); |
| | 0 | 193 | | }}); |
| | 0 | 194 | | }})(); |
| | 0 | 195 | | </script>"; |
| | | 196 | | } |
| | | 197 | | } |