| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text.RegularExpressions; |
| | | 3 | | using Microsoft.AspNetCore.Http; |
| | | 4 | | using Microsoft.AspNetCore.Mvc; |
| | | 5 | | using Microsoft.AspNetCore.Mvc.Controllers; |
| | | 6 | | using Microsoft.AspNetCore.Mvc.Filters; |
| | | 7 | | using OzonDomains.Models; |
| | | 8 | | using OzonOrdersWeb.Authorization; |
| | | 9 | | |
| | | 10 | | namespace OzonOrdersWeb.Observability; |
| | | 11 | | |
| | 9 | 12 | | public sealed class InvalidModelStateLoggingFilter(ILogger<InvalidModelStateLoggingFilter> logger) : IActionFilter |
| | | 13 | | { |
| | | 14 | | private const string LoggedItemKey = "__studio2_modelstate_logged"; |
| | | 15 | | |
| | | 16 | | public void OnActionExecuting(ActionExecutingContext context) |
| | | 17 | | { |
| | 9 | 18 | | if (IsServiceOrApiEndpoint(context)) |
| | | 19 | | { |
| | 1 | 20 | | return; |
| | | 21 | | } |
| | | 22 | | |
| | 8 | 23 | | PruneTrivialEmptyBooleanErrors(context.ModelState, context.ActionArguments, context.ActionDescriptor.Parameters) |
| | 8 | 24 | | PruneEmptyExtraExpensesErrors(context.ModelState, context.ActionArguments); |
| | | 25 | | |
| | 8 | 26 | | if (context.ModelState.IsValid) |
| | | 27 | | { |
| | 3 | 28 | | return; |
| | | 29 | | } |
| | | 30 | | |
| | 5 | 31 | | Log(context.HttpContext, context.RouteData, context.ModelState); |
| | 5 | 32 | | } |
| | | 33 | | |
| | | 34 | | public void OnActionExecuted(ActionExecutedContext context) |
| | | 35 | | { |
| | 1 | 36 | | if (IsServiceOrApiEndpoint(context)) |
| | | 37 | | { |
| | 0 | 38 | | return; |
| | | 39 | | } |
| | | 40 | | |
| | 1 | 41 | | PruneTrivialEmptyBooleanErrors(context.ModelState, null, context.ActionDescriptor.Parameters); |
| | 1 | 42 | | PruneEmptyExtraExpensesErrors(context.ModelState, null); |
| | | 43 | | |
| | 1 | 44 | | if (!context.ModelState.IsValid && !context.HttpContext.Items.ContainsKey(LoggedItemKey)) |
| | | 45 | | { |
| | 0 | 46 | | Log(context.HttpContext, context.RouteData, context.ModelState); |
| | | 47 | | } |
| | 1 | 48 | | } |
| | | 49 | | |
| | | 50 | | private static bool IsServiceOrApiEndpoint(ActionContext context) |
| | | 51 | | { |
| | 10 | 52 | | var endpoint = context.HttpContext.GetEndpoint(); |
| | 10 | 53 | | if (endpoint?.Metadata.GetMetadata<ServiceEndpointAttribute>() is not null |
| | 10 | 54 | | || endpoint?.Metadata.GetMetadata<ApiControllerAttribute>() is not null) |
| | | 55 | | { |
| | 0 | 56 | | return true; |
| | | 57 | | } |
| | | 58 | | |
| | 10 | 59 | | if (context.ActionDescriptor?.EndpointMetadata is not null) |
| | | 60 | | { |
| | 21 | 61 | | foreach (var meta in context.ActionDescriptor.EndpointMetadata) |
| | | 62 | | { |
| | 1 | 63 | | if (meta is ServiceEndpointAttribute or ApiControllerAttribute) |
| | | 64 | | { |
| | 1 | 65 | | return true; |
| | | 66 | | } |
| | | 67 | | } |
| | | 68 | | } |
| | | 69 | | |
| | 9 | 70 | | if (context.ActionDescriptor is ControllerActionDescriptor cad) |
| | | 71 | | { |
| | 0 | 72 | | if (cad.MethodInfo.GetCustomAttributes(typeof(ServiceEndpointAttribute), inherit: true).Length > 0 |
| | 0 | 73 | | || cad.ControllerTypeInfo.GetCustomAttributes(typeof(ServiceEndpointAttribute), inherit: true).Length > |
| | 0 | 74 | | || cad.MethodInfo.GetCustomAttributes(typeof(ApiControllerAttribute), inherit: true).Length > 0 |
| | 0 | 75 | | || cad.ControllerTypeInfo.GetCustomAttributes(typeof(ApiControllerAttribute), inherit: true).Length > 0) |
| | | 76 | | { |
| | 0 | 77 | | return true; |
| | | 78 | | } |
| | | 79 | | } |
| | | 80 | | |
| | 9 | 81 | | return false; |
| | 1 | 82 | | } |
| | | 83 | | |
| | | 84 | | internal static void PruneTrivialEmptyBooleanErrors( |
| | | 85 | | Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelState, |
| | | 86 | | IDictionary<string, object?>? actionArguments, |
| | | 87 | | IList<Microsoft.AspNetCore.Mvc.Abstractions.ParameterDescriptor>? parameters) |
| | | 88 | | { |
| | 9 | 89 | | if (modelState.IsValid || modelState.Count == 0) |
| | | 90 | | { |
| | 1 | 91 | | return; |
| | | 92 | | } |
| | | 93 | | |
| | 8 | 94 | | var keysToRemove = new List<string>(); |
| | | 95 | | |
| | 52 | 96 | | foreach (var (key, entry) in modelState) |
| | | 97 | | { |
| | 18 | 98 | | if (entry.Errors.Count == 0) |
| | | 99 | | { |
| | | 100 | | continue; |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | // Ошибка вызвана пустой строкой в не-nullable поле |
| | 14 | 104 | | if (!string.IsNullOrEmpty(entry.AttemptedValue)) |
| | | 105 | | { |
| | | 106 | | continue; |
| | | 107 | | } |
| | | 108 | | |
| | 12 | 109 | | if (IsBooleanTarget(key, actionArguments, parameters)) |
| | | 110 | | { |
| | 3 | 111 | | keysToRemove.Add(key); |
| | | 112 | | } |
| | | 113 | | } |
| | | 114 | | |
| | 22 | 115 | | foreach (var key in keysToRemove) |
| | | 116 | | { |
| | 3 | 117 | | modelState.Remove(key); |
| | | 118 | | } |
| | 8 | 119 | | } |
| | | 120 | | |
| | 1 | 121 | | private static readonly Regex ExtraExpensesPrefixRegex = |
| | 1 | 122 | | new(@"^(?<prefix>(?:.*?\.)?extraexpenses\[\d+\])(?:\.(?<field>.*))?$", |
| | 1 | 123 | | RegexOptions.IgnoreCase | RegexOptions.Compiled); |
| | | 124 | | |
| | | 125 | | internal static void PruneEmptyExtraExpensesErrors( |
| | | 126 | | Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelState, |
| | | 127 | | IDictionary<string, object?>? actionArguments) |
| | | 128 | | { |
| | 9 | 129 | | if (modelState.Count == 0) |
| | | 130 | | { |
| | 2 | 131 | | return; |
| | | 132 | | } |
| | | 133 | | |
| | 7 | 134 | | var prefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 44 | 135 | | foreach (var key in modelState.Keys) |
| | | 136 | | { |
| | 15 | 137 | | var match = ExtraExpensesPrefixRegex.Match(key); |
| | 15 | 138 | | if (match.Success) |
| | | 139 | | { |
| | 10 | 140 | | prefixes.Add(match.Groups["prefix"].Value); |
| | | 141 | | } |
| | | 142 | | } |
| | | 143 | | |
| | 7 | 144 | | if (prefixes.Count == 0) |
| | | 145 | | { |
| | 4 | 146 | | return; |
| | | 147 | | } |
| | | 148 | | |
| | 3 | 149 | | var keysToRemove = new List<string>(); |
| | | 150 | | |
| | 12 | 151 | | foreach (var prefix in prefixes) |
| | | 152 | | { |
| | 3 | 153 | | string? attemptedAmount = null; |
| | 3 | 154 | | string? attemptedExpenseTypeId = null; |
| | | 155 | | |
| | 28 | 156 | | foreach (var (key, entry) in modelState) |
| | | 157 | | { |
| | 11 | 158 | | var match = ExtraExpensesPrefixRegex.Match(key); |
| | 11 | 159 | | if (!match.Success || !match.Groups["prefix"].Value.Equals(prefix, StringComparison.OrdinalIgnoreCase)) |
| | | 160 | | { |
| | | 161 | | continue; |
| | | 162 | | } |
| | | 163 | | |
| | 10 | 164 | | var field = match.Groups["field"].Value; |
| | 10 | 165 | | if (field.Equals("Amount", StringComparison.OrdinalIgnoreCase)) |
| | | 166 | | { |
| | 3 | 167 | | attemptedAmount = entry.AttemptedValue; |
| | | 168 | | } |
| | 7 | 169 | | else if (field.Equals("ExpenseTypeId", StringComparison.OrdinalIgnoreCase)) |
| | | 170 | | { |
| | 3 | 171 | | attemptedExpenseTypeId = entry.AttemptedValue; |
| | | 172 | | } |
| | | 173 | | } |
| | | 174 | | |
| | 3 | 175 | | var hasValidAmount = TryParsePositiveDecimal(attemptedAmount, out _); |
| | 3 | 176 | | var hasValidExpenseTypeId = TryParsePositiveInt(attemptedExpenseTypeId, out _); |
| | | 177 | | |
| | 3 | 178 | | if (!hasValidAmount && !hasValidExpenseTypeId) |
| | | 179 | | { |
| | 22 | 180 | | foreach (var key in modelState.Keys) |
| | | 181 | | { |
| | 9 | 182 | | var match = ExtraExpensesPrefixRegex.Match(key); |
| | 9 | 183 | | if (match.Success && match.Groups["prefix"].Value.Equals(prefix, StringComparison.OrdinalIgnoreCase) |
| | | 184 | | { |
| | 8 | 185 | | keysToRemove.Add(key); |
| | | 186 | | } |
| | | 187 | | } |
| | | 188 | | } |
| | | 189 | | } |
| | | 190 | | |
| | 22 | 191 | | foreach (var key in keysToRemove) |
| | | 192 | | { |
| | 8 | 193 | | modelState.Remove(key); |
| | | 194 | | } |
| | | 195 | | |
| | 3 | 196 | | if (actionArguments != null) |
| | | 197 | | { |
| | 12 | 198 | | foreach (var arg in actionArguments.Values) |
| | | 199 | | { |
| | 3 | 200 | | if (arg == null) continue; |
| | | 201 | | |
| | 3 | 202 | | if (arg is IEnumerable<Order> ordersList) |
| | | 203 | | { |
| | 3 | 204 | | CleanOrders(ordersList); |
| | | 205 | | } |
| | | 206 | | else |
| | | 207 | | { |
| | 0 | 208 | | var ordersProp = arg.GetType().GetProperty("Orders"); |
| | 0 | 209 | | if (ordersProp != null && ordersProp.GetValue(arg) is IEnumerable<Order> nestedOrders) |
| | | 210 | | { |
| | 0 | 211 | | CleanOrders(nestedOrders); |
| | | 212 | | } |
| | | 213 | | } |
| | | 214 | | } |
| | | 215 | | } |
| | 3 | 216 | | } |
| | | 217 | | |
| | | 218 | | private static void CleanOrders(IEnumerable<Order> orders) |
| | | 219 | | { |
| | 12 | 220 | | foreach (var order in orders) |
| | | 221 | | { |
| | 3 | 222 | | if (order.ExtraExpenses != null && order.ExtraExpenses.Count > 0) |
| | | 223 | | { |
| | 2 | 224 | | order.ExtraExpenses.RemoveAll(e => e == null || (e.ExpenseTypeId <= 0 && e.Amount <= 0)); |
| | | 225 | | } |
| | | 226 | | } |
| | 3 | 227 | | } |
| | | 228 | | |
| | | 229 | | private static bool TryParsePositiveDecimal(string? value, out decimal result) |
| | | 230 | | { |
| | 3 | 231 | | result = 0; |
| | 6 | 232 | | if (string.IsNullOrWhiteSpace(value)) return false; |
| | 0 | 233 | | var normalized = value.Trim().Replace(',', '.'); |
| | 0 | 234 | | return decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out result) && result > 0; |
| | | 235 | | } |
| | | 236 | | |
| | | 237 | | private static bool TryParsePositiveInt(string? value, out int result) |
| | | 238 | | { |
| | 3 | 239 | | result = 0; |
| | 5 | 240 | | if (string.IsNullOrWhiteSpace(value)) return false; |
| | 1 | 241 | | return int.TryParse(value.Trim(), out result) && result > 0; |
| | | 242 | | } |
| | | 243 | | |
| | | 244 | | private static bool IsBooleanTarget( |
| | | 245 | | string key, |
| | | 246 | | IDictionary<string, object?>? actionArguments, |
| | | 247 | | IList<Microsoft.AspNetCore.Mvc.Abstractions.ParameterDescriptor>? parameters) |
| | | 248 | | { |
| | 12 | 249 | | var propName = key.Contains('.') ? key.Split('.').Last() : key; |
| | | 250 | | |
| | 12 | 251 | | if (actionArguments != null) |
| | | 252 | | { |
| | 37 | 253 | | foreach (var arg in actionArguments.Values) |
| | | 254 | | { |
| | 9 | 255 | | if (arg == null) continue; |
| | 9 | 256 | | var prop = arg.GetType().GetProperty(propName, System.Reflection.BindingFlags.Public | System.Reflection |
| | 9 | 257 | | if (prop != null && (prop.PropertyType == typeof(bool) || prop.PropertyType == typeof(bool?))) |
| | | 258 | | { |
| | 3 | 259 | | return true; |
| | | 260 | | } |
| | | 261 | | } |
| | | 262 | | } |
| | | 263 | | |
| | 9 | 264 | | if (parameters != null) |
| | | 265 | | { |
| | 18 | 266 | | foreach (var param in parameters) |
| | | 267 | | { |
| | 0 | 268 | | if (param.ParameterType.IsClass && param.ParameterType != typeof(string)) |
| | | 269 | | { |
| | 0 | 270 | | var prop = param.ParameterType.GetProperty(propName, System.Reflection.BindingFlags.Public | System. |
| | 0 | 271 | | if (prop != null && (prop.PropertyType == typeof(bool) || prop.PropertyType == typeof(bool?))) |
| | | 272 | | { |
| | 0 | 273 | | return true; |
| | | 274 | | } |
| | | 275 | | } |
| | 0 | 276 | | else if (param.Name.Equals(propName, StringComparison.OrdinalIgnoreCase) |
| | 0 | 277 | | && (param.ParameterType == typeof(bool) || param.ParameterType == typeof(bool?))) |
| | | 278 | | { |
| | 0 | 279 | | return true; |
| | | 280 | | } |
| | | 281 | | } |
| | | 282 | | } |
| | | 283 | | |
| | 9 | 284 | | return false; |
| | 3 | 285 | | } |
| | | 286 | | |
| | | 287 | | private void Log(HttpContext httpContext, RouteData routeData, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDicti |
| | | 288 | | { |
| | 5 | 289 | | httpContext.Items[LoggedItemKey] = true; |
| | 5 | 290 | | var errors = modelState |
| | 6 | 291 | | .Where(pair => pair.Value is { Errors.Count: > 0 }) |
| | 5 | 292 | | .ToDictionary( |
| | 5 | 293 | | pair => pair.Key, |
| | 10 | 294 | | pair => pair.Value!.Errors.Select(error => |
| | 15 | 295 | | string.IsNullOrWhiteSpace(error.ErrorMessage) |
| | 15 | 296 | | ? error.Exception?.GetType().Name ?? "ValidationError" |
| | 15 | 297 | | : error.ErrorMessage).ToArray()); |
| | | 298 | | |
| | 10 | 299 | | var summary = string.Join("; ", errors.Take(3).Select(e => $"{e.Key}: {string.Join(", ", e.Value)}")); |
| | 5 | 300 | | if (errors.Count > 3) |
| | | 301 | | { |
| | 0 | 302 | | summary += $" (+{errors.Count - 3} ещё)"; |
| | | 303 | | } |
| | | 304 | | |
| | 5 | 305 | | using var scope = logger.BeginScope(new Dictionary<string, object?> |
| | 5 | 306 | | { |
| | 5 | 307 | | ["EventKind"] = "FormValidationFailed", |
| | 5 | 308 | | ["EventCategory"] = "forms", |
| | 5 | 309 | | ["RequestPath"] = httpContext.Request.Path.Value, |
| | 5 | 310 | | ["HttpMethod"] = httpContext.Request.Method, |
| | 5 | 311 | | ["Controller"] = routeData.Values["controller"]?.ToString(), |
| | 5 | 312 | | ["Action"] = routeData.Values["action"]?.ToString(), |
| | 5 | 313 | | ["TraceId"] = httpContext.TraceIdentifier, |
| | 5 | 314 | | ["ValidationErrors"] = errors |
| | 5 | 315 | | }); |
| | | 316 | | |
| | 5 | 317 | | logger.LogWarning("Форма {Method} {Path} не прошла серверную валидацию: {ValidationSummary}", |
| | 5 | 318 | | httpContext.Request.Method, httpContext.Request.Path, summary); |
| | 10 | 319 | | } |
| | | 320 | | } |