diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..4b00fe300d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Build and Deploy Smartstore to Plesk + +on: + push: + branches: + - main_shs + workflow_dispatch: # Ermöglicht den manuellen Start per Knopfdruck + +jobs: + build-and-deploy: + runs-on: windows-latest + + steps: + # 1. Quellcode aus GitHub auschecken + - name: Checkout Source Code + uses: actions/checkout@v4 + + # 2. .NET SDK bereitstellen + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + # 3. Projektabhängigkeiten wiederherstellen + - name: Restore dependencies + run: dotnet restore Smartstore.sln + + # 4. Smartstore kompilieren (Publish erzeugen) + - name: Publish Smartstore.Web + run: | + dotnet publish src/Smartstore.Web/Smartstore.Web.csproj -c Release -r win-x86 --self-contained false -o ./publish_output /p:CopyLocalLockFileAssemblies=true /p:RazorCompileOnBuild=false /p:PublishModules=true + + # 4b. App Offline schalten (Verhindert Datei-Sperren in Plesk und erzwingt Neustart) + - name: Create app_offline.htm + run: | + echo "

Shop wird aktualisiert...

" > ./publish_output/app_offline.htm + + # 5. Die fertig kompilierten Dateien per FTP zu Plesk hochladen + # Korrektur: local-dir nutzt jetzt exakt den korrekten Ordner + - name: FTP Deploy to Plesk + uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + with: + server: ${{ secrets.FTP_SERVER }} + username: 'thuneck' + password: ${{ secrets.FTP_PASSWORD }} + local-dir: ./publish_output/ + server-dir: /httpdocs/ + # dangerous-clean-slate: true # <--- ZWINGT DIE ACTION ALLES NEU ZU SCHREIBEN! diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3499b2c8fd..b77d487864 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - os: [debian-latest, windows-latest] + os: [windows-latest] permissions: contents: read packages: write @@ -53,17 +53,17 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Publish and Push for Linux - if: matrix.os == 'debian-latest' + if: matrix.os == 'ubuntu-latest' run: | dotnet publish src/Smartstore.Web/Smartstore.Web.csproj -c Release -o ./.build/release --no-restore --no-build --no-self-contained - docker build --build-arg SOURCE=./.build/release -f NoBuild.Dockerfile -t ghcr.io/smartstore/smartstore-linux:latest -t ghcr.io/smartstore/smartstore-linux:${{ steps.format-branch-label.outputs.branch-label }} . - docker push ghcr.io/smartstore/smartstore-linux:latest - docker push ghcr.io/smartstore/smartstore-linux:${{ steps.format-branch-label.outputs.branch-label }} + docker build --build-arg SOURCE=./.build/release -f NoBuild.Dockerfile -t ghcr.io/thuneck-debug/smartstore-linux:latest -t ghcr.io/thuneck-debug/smartstore-linux:${{ steps.format-branch-label.outputs.branch-label }} . + docker push ghcr.io/thuneck-debug/smartstore-linux:latest + docker push ghcr.io/thuneck-debug/smartstore-linux:${{ steps.format-branch-label.outputs.branch-label }} - name: Publish and Push for Windows if: matrix.os == 'windows-latest' run: | dotnet publish src/Smartstore.Web/Smartstore.Web.csproj -c Release -o ./.build/release --no-restore --no-build --no-self-contained - docker build --build-arg SOURCE=./.build/release -f Nano.Dockerfile -t ghcr.io/smartstore/smartstore-windows:latest -t ghcr.io/smartstore/smartstore-windows:${{ steps.format-branch-label.outputs.branch-label }} . - docker push ghcr.io/smartstore/smartstore-windows:latest - docker push ghcr.io/smartstore/smartstore-windows:${{ steps.format-branch-label.outputs.branch-label }} + docker build --build-arg SOURCE=./.build/release -f Nano.Dockerfile -t ghcr.io/thuneck-debug/smartstore-windows:latest -t ghcr.io/thuneck-debug/smartstore-windows:${{ steps.format-branch-label.outputs.branch-label }} . + docker push ghcr.io/thuneck-debug/smartstore-windows:latest + docker push ghcr.io/thuneck-debug/smartstore-windows:${{ steps.format-branch-label.outputs.branch-label }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000000..38e5900b1c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,34 @@ +name: Build and Publish Smartstore 6.3 + +on: + push: + branches: + - main_shs # Hier den exakten Namen Ihres Branches eintragen + +jobs: + build-and-publish: + runs-on: windows-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' # Erzwingt .NET 9 für Version 6.3 + + - name: Restore Dependencies + run: dotnet restore Smartstore.sln + + - name: Build and Publish + run: | + dotnet publish Smartstore.sln -c Release -r win-x86 --self-contained false -o ./publish-folder + +# Erklaerung: -r win-x86 stellt auf 32-Bit fuer Fritz IT ein. --self-contained false nutzt das Server-.NET-9. + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: smartstore-plesk-package + path: ./publish-folder diff --git a/src/Smartstore.Core/Catalog/Pricing/Services/Calculators/TierPriceCalculator.cs b/src/Smartstore.Core/Catalog/Pricing/Services/Calculators/TierPriceCalculator.cs index 198acecbb1..a2493289f4 100644 --- a/src/Smartstore.Core/Catalog/Pricing/Services/Calculators/TierPriceCalculator.cs +++ b/src/Smartstore.Core/Catalog/Pricing/Services/Calculators/TierPriceCalculator.cs @@ -47,7 +47,8 @@ public async Task CalculateAsync(CalculatorContext context, CalculatorDelegate n // but exclude additional charge from comparing. context.FinalPrice -= context.AdditionalCharge; - if (context.MinTierPrice.Value < context.FinalPrice) + // INFO: A tier price may equal the regular price. + if (context.MinTierPrice.Value <= context.FinalPrice) { context.FinalPrice = context.MinTierPrice.Value; context.AppliedTierPrice = tierPriceEntity; diff --git a/src/Smartstore.Core/Catalog/Rules/ProductRuleProvider.cs b/src/Smartstore.Core/Catalog/Rules/ProductRuleProvider.cs index 7075375709..1abd96ae31 100644 --- a/src/Smartstore.Core/Catalog/Rules/ProductRuleProvider.cs +++ b/src/Smartstore.Core/Catalog/Rules/ProductRuleProvider.cs @@ -127,6 +127,7 @@ public async Task MatchesAsync( .BuildFacetMap(false) .CheckSpelling(0); + searchQuery.CustomData["RuleCheckProductId"] = productId; searchQuery = group.ApplyFilters(searchQuery); var productQuery = _catalogSearchService.PrepareQuery(searchQuery); @@ -211,7 +212,7 @@ CatalogSearchQuery categoryFilter(SearchFilterContext ctx, int[] categoryIds) { if (!categoryIds.IsNullOrEmpty()) { - var featuredOnly = _catalogSettings.IncludeFeaturedProductsInNormalLists ? (bool?)null : false; + bool? featuredOnly = (_catalogSettings.IncludeFeaturedProductsInNormalLists || ctx.Query.CustomData.ContainsKey("RuleCheckProductId")) ? null : false; if (_catalogSettings.ShowProductsFromSubcategories) { diff --git a/src/Smartstore.Core/Checkout/Orders/Services/OrderCalculationService.cs b/src/Smartstore.Core/Checkout/Orders/Services/OrderCalculationService.cs index a236f318c0..8e84008642 100644 --- a/src/Smartstore.Core/Checkout/Orders/Services/OrderCalculationService.cs +++ b/src/Smartstore.Core/Checkout/Orders/Services/OrderCalculationService.cs @@ -1109,7 +1109,13 @@ protected virtual async Task GetAdjustedShippingTotalAsync(Shoppin var response = await _shippingService.GetShippingOptionsAsync(cart, customer.ShippingAddress, null, cart.StoreId); if (response.Success && response.ShippingOptions.Count > 0) { - shipping.Option = response.ShippingOptions[0]; + var preferredMethodId = customer.GenericAttributes?.PreferredShippingOption?.ShippingMethodId ?? 0; + if (preferredMethodId != 0) + { + shipping.Option = response.ShippingOptions.FirstOrDefault(x => x.ShippingMethodId == preferredMethodId); + } + + shipping.Option ??= response.ShippingOptions[0]; } } diff --git a/src/Smartstore.Core/Content/Media/MediaService.cs b/src/Smartstore.Core/Content/Media/MediaService.cs index 20bf1215b6..32f388fd55 100644 --- a/src/Smartstore.Core/Content/Media/MediaService.cs +++ b/src/Smartstore.Core/Content/Media/MediaService.cs @@ -87,7 +87,6 @@ public async Task CountFilesAsync(MediaSearchQuery query) public async Task CountFilesGroupedAsync(MediaFilesFilter filter) { - // TODO: (core) Throws Guard.NotNull(filter); // Base db query diff --git a/src/Smartstore.Core/Platform/AI/Metadata/AIMetadata.cs b/src/Smartstore.Core/Platform/AI/Metadata/AIMetadata.cs index 5ba8dacca9..11ca3be674 100644 --- a/src/Smartstore.Core/Platform/AI/Metadata/AIMetadata.cs +++ b/src/Smartstore.Core/Platform/AI/Metadata/AIMetadata.cs @@ -37,6 +37,12 @@ public class AIMetadata /// public AIModelCollection Models { get; set; } = default!; + /// + /// A flag indicating whether this instance is a post-processed clone. + /// + [JsonIgnore] + public bool PostProcessed { get; set; } + #endregion #region Supports... diff --git a/src/Smartstore.Core/Platform/AI/Metadata/IAIMetadataLoader.cs b/src/Smartstore.Core/Platform/AI/Metadata/IAIMetadataLoader.cs index 15b0eb96c3..6d9e248c46 100644 --- a/src/Smartstore.Core/Platform/AI/Metadata/IAIMetadataLoader.cs +++ b/src/Smartstore.Core/Platform/AI/Metadata/IAIMetadataLoader.cs @@ -1,4 +1,6 @@ -namespace Smartstore.Core.AI.Metadata +#nullable enable + +namespace Smartstore.Core.AI.Metadata { /// /// Loads and caches AI metadata. @@ -11,5 +13,17 @@ public interface IAIMetadataLoader /// The AI modules's system name to load metadata.json from." /// AIMetadata LoadMetadata(string moduleSystemName); + + /// + /// Performs post-processing operations asynchronously using the specified AI metadata. + /// + /// The metadata object containing information required for post-processing. Cannot be null. + /// Return null if no post-processing was performed. Return an instance to update the metadata cache deferredly. + Task PostProcessAsync(AIMetadata localMetadata); + + /// + /// Replaces the cached metadata for the given with the given instance. + /// + void ReplaceMetadata(string moduleSystemName, AIMetadata metadata); } } diff --git a/src/Smartstore.Core/Platform/AI/Metadata/JsonAIMetadataLoader.cs b/src/Smartstore.Core/Platform/AI/Metadata/JsonAIMetadataLoader.cs index 458b53d8fb..b72b77fd7a 100644 --- a/src/Smartstore.Core/Platform/AI/Metadata/JsonAIMetadataLoader.cs +++ b/src/Smartstore.Core/Platform/AI/Metadata/JsonAIMetadataLoader.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Primitives; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; +using Smartstore.Caching; using Smartstore.ComponentModel; namespace Smartstore.Core.AI.Metadata @@ -27,11 +28,19 @@ public JsonAIMetadataLoader(IMemoryCache cache, IApplicationContext appContext) }; } + protected static string BuildCacheKey(string moduleSystemName) + => "aimetadata:" + moduleSystemName; + + protected IMemoryCache Cache + { + get => _cache; + } + public AIMetadata LoadMetadata(string moduleSystemName) { Guard.NotEmpty(moduleSystemName); - var cacheKey = "aimetadata:" + moduleSystemName; + var cacheKey = BuildCacheKey(moduleSystemName); var result = _cache.GetOrCreate(cacheKey, entry => { @@ -42,10 +51,27 @@ public AIMetadata LoadMetadata(string moduleSystemName) entry.AddExpirationToken(changeToken); } - return metadata; + return new CacheEntry { Key = cacheKey, Value = metadata, ValueType = typeof(AIMetadata) }; }); - return result!; + return (AIMetadata)result!.Value; + } + + public virtual Task PostProcessAsync(AIMetadata localMetadata) + { + localMetadata.PostProcessed = true; + return Task.FromResult(null); + } + + public void ReplaceMetadata(string moduleSystemName, AIMetadata metadata) + { + Guard.NotEmpty(moduleSystemName); + Guard.NotNull(metadata); + + if (_cache.TryGetValue(BuildCacheKey(moduleSystemName), out CacheEntry? entry)) + { + entry!.Value = metadata; + } } protected virtual (AIMetadata, IChangeToken?) LoadMetadataCore(string moduleSystemName) @@ -77,7 +103,7 @@ protected virtual (AIMetadata, IChangeToken?) LoadMetadataCore(string moduleSyst public void Invalidate(string moduleSystemName) { - _cache.Remove("aimetadata:" + moduleSystemName); + _cache.Remove(BuildCacheKey(moduleSystemName)); } } } diff --git a/src/Smartstore.Core/Platform/AI/Services/AIProviderBase.cs b/src/Smartstore.Core/Platform/AI/Services/AIProviderBase.cs index d11501027e..74bfe2d0dd 100644 --- a/src/Smartstore.Core/Platform/AI/Services/AIProviderBase.cs +++ b/src/Smartstore.Core/Platform/AI/Services/AIProviderBase.cs @@ -3,6 +3,7 @@ using Smartstore.Core.Content.Media; using Smartstore.Core.Localization; using Smartstore.IO; +using Smartstore.Threading; using Smartstore.Utilities; namespace Smartstore.Core.AI @@ -28,11 +29,85 @@ public abstract class AIProviderBase : IAIProvider /// protected const string ContinueReason = "length"; + private readonly IAIMetadataLoader _metadataLoader; + private readonly string _moduleSystemName; + private AIMetadata _metadata; + + public AIProviderBase(IAIMetadataLoader metadataLoader, string moduleSystemName) + { + _metadataLoader = metadataLoader; + _moduleSystemName = moduleSystemName; + } + public Localizer T { get; set; } = NullLocalizer.Instance; public abstract bool IsActive(); - public virtual AIMetadata Metadata { get; protected set; } + public AIMetadata Metadata + { + get + { + if (_metadata == null) + { + _metadata = _metadataLoader.LoadMetadata(_moduleSystemName); + + // Ensure that metadata is post-processed only once. + if (!_metadata.PostProcessed) + { + // Post-process metadata asynchronously (fire-and-forget). + var lockHandle = AsyncLock.Keyed("aimetadatalock:" + _metadata.ProviderId); + // Reload from cache + var metadata = _metadataLoader.LoadMetadata(_moduleSystemName); + if (!metadata.PostProcessed) + { + PostProcessMetadataInBackground(metadata, lockHandle); + } + else + { + lockHandle.Release(); + } + } + } + + return _metadata; + } + protected set => _metadata = value; + } + + private void PostProcessMetadataInBackground(AIMetadata metadata, ILockHandle lockHandle) + { + var task = _metadataLoader.PostProcessAsync(metadata); + + if (task.IsCompleted) + { + // If already completed, update metadata immediately. + if (task.Status == TaskStatus.RanToCompletion && task.Result != null) + { + _metadata = task.Result; + } + + _metadata.PostProcessed = true; + lockHandle.Release(); + } + else + { + // Update cached metadata when post-processing is done. + task.ContinueWith(t => + { + if (t.Result != null) + { + t.Result.PostProcessed = true; + _metadataLoader.ReplaceMetadata(_moduleSystemName, t.Result); + } + else + { + _metadata.PostProcessed = true; + } + + lockHandle.Release(); + }); + } + } public virtual AIModelCollection GetModels(AIChatTopic topic) { diff --git a/src/Smartstore.Core/Platform/Common/DefaultWorkContextSource.cs b/src/Smartstore.Core/Platform/Common/DefaultWorkContextSource.cs index bd110d6162..d077c7b769 100644 --- a/src/Smartstore.Core/Platform/Common/DefaultWorkContextSource.cs +++ b/src/Smartstore.Core/Platform/Common/DefaultWorkContextSource.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; using Smartstore.Caching; using Smartstore.Core.Checkout.Tax; using Smartstore.Core.Common; @@ -294,7 +295,7 @@ public virtual async Task ResolveWorkingCurrencyAsync(Customer custome } // Default currency of country to which the current IP address belongs. - if (currency == null) + if (currency == null && !customer.IsSystemAccount) { var ipAddress = _webHelper.GetClientIpAddress(); var lookupCountry = _geoCountryLookup.LookupCountry(ipAddress); @@ -308,7 +309,7 @@ public virtual async Task ResolveWorkingCurrencyAsync(Customer custome if (country?.DefaultCurrency?.Published == true) { - currency = country.DefaultCurrency; + currency = storeCurrenciesMap.Get(country.DefaultCurrency.Id); } } } @@ -486,7 +487,55 @@ private static Task DetectPdfConverter(DetectCustomerContext context) private static async Task DetectBot(DetectCustomerContext context) { - if (context.UserAgent.IsBot()) + var req = context.HttpContext.Request; + var headers = req.Headers; + var isBot = false; + + // User Agent (Very cheap & effective against dumb bots) + var ua = req.UserAgent(); + if (string.IsNullOrWhiteSpace(ua) || ua.Length < 10) // "Mozilla" is 7 chars + { + isBot = true; + } + + // Accept-Language (Standard for ANY browser used by humans) + // Missing Accept-Language is extremely rare for real users. + if (!isBot) + { + isBot = !headers.ContainsKey(HeaderNames.AcceptLanguage); + } + + // Missing Accept header is suspicious for storefront browsing + if (!isBot && (!headers.TryGetValue(HeaderNames.Accept, out var accept) || accept.Count == 0)) + { + isBot = true; + } + + // User-Agent vs. Sec-Fetch Headers Consistency (Modern Chromium browsers on top-level navigation) + if (!isBot + && req.IsNonAjaxGet() + && (ua.ContainsNoCase("Chrome/") || ua.ContainsNoCase("Edg/"))) + { + var hasSecFetch = + headers.ContainsKey("Sec-Fetch-Site") || + headers.ContainsKey("Sec-Fetch-Mode") || + headers.ContainsKey("Sec-Fetch-Dest") || + headers.ContainsKey("Sec-Fetch-User"); + + // Missing Sec-Fetch headers in modern browsers is suspicious. + if (!hasSecFetch) + { + isBot = true; + } + } + + if (!isBot) + { + // Fallback to existing UA parser logic (Regex based) + isBot = context.UserAgent.IsBot(); + } + + if (isBot) { // Check traffic limit for bots. await CheckBotDeniedAsync(context); diff --git a/src/Smartstore.Core/Platform/DataExchange/Export/ExportXmlHelper.cs b/src/Smartstore.Core/Platform/DataExchange/Export/ExportXmlHelper.cs index b8247be29b..45feca795f 100644 --- a/src/Smartstore.Core/Platform/DataExchange/Export/ExportXmlHelper.cs +++ b/src/Smartstore.Core/Platform/DataExchange/Export/ExportXmlHelper.cs @@ -692,7 +692,8 @@ public void WriteProduct(dynamic product, string node) _writer.WriteElementString(nameof(Product.DisableWishlistButton), entity.DisableWishlistButton.ToString()); _writer.WriteElementString(nameof(Product.AvailableForPreOrder), entity.AvailableForPreOrder.ToString()); _writer.WriteElementString(nameof(Product.CallForPrice), entity.CallForPrice.ToString()); - _writer.WriteElementString(nameof(Product.Price), ((decimal)product.Price).ToString(_culture)); + // INFO: Export the entered price. Not "(decimal)product.Price" because it is the calculated price. + _writer.WriteElementString(nameof(Product.Price), entity.Price.ToString(_culture)); _writer.WriteElementString(nameof(Product.ComparePrice), entity.ComparePrice.ToString(_culture)); _writer.WriteElementString(nameof(Product.ProductCost), entity.ProductCost.ToString(_culture)); _writer.WriteElementString(nameof(Product.SpecialPrice), entity.SpecialPrice?.ToString(_culture) ?? string.Empty); diff --git a/src/Smartstore.Core/Platform/Modularity/ModuleManager.cs b/src/Smartstore.Core/Platform/Modularity/ModuleManager.cs index f00cc8b82d..1e27170507 100644 --- a/src/Smartstore.Core/Platform/Modularity/ModuleManager.cs +++ b/src/Smartstore.Core/Platform/Modularity/ModuleManager.cs @@ -210,10 +210,11 @@ public async Task ApplyLocalizedValueAsync(IProviderMetadata metadata, int langu // Delete _db.LocaleStringResources.Remove(resource); } - else + else if (value != resource.ResourceValue) { // Update resource.ResourceValue = value; + resource.IsTouched = true; } } else @@ -226,6 +227,7 @@ public async Task ApplyLocalizedValueAsync(IProviderMetadata metadata, int langu LanguageId = languageId, ResourceName = resourceName, ResourceValue = value, + IsTouched = true }); } } diff --git a/src/Smartstore.Core/Platform/Rules/Filters/FilterExpressionParser.cs b/src/Smartstore.Core/Platform/Rules/Filters/FilterExpressionParser.cs index 8d8bab9e20..601dbb1943 100644 --- a/src/Smartstore.Core/Platform/Rules/Filters/FilterExpressionParser.cs +++ b/src/Smartstore.Core/Platform/Rules/Filters/FilterExpressionParser.cs @@ -335,7 +335,7 @@ private static RuleOperator ConvertOperator(string op, TextSpan termSpan) if (hasAnyWildcard) { - if (op is (null or "~" or "=" or "==")) + if (op.NullEmpty() is (null or "~" or "=" or "==")) { return RuleOperator.Like; } diff --git a/src/Smartstore.Core/Platform/Security/Configuration/GoogleRecaptchaSettings.cs b/src/Smartstore.Core/Platform/Security/Configuration/GoogleRecaptchaSettings.cs index d4259b33d4..a5883c2dc7 100644 --- a/src/Smartstore.Core/Platform/Security/Configuration/GoogleRecaptchaSettings.cs +++ b/src/Smartstore.Core/Platform/Security/Configuration/GoogleRecaptchaSettings.cs @@ -18,7 +18,7 @@ public class GoogleRecaptchaSettings : ISettings /// /// v2 | v3 /// - public string Version { get; set; } = "v2"; + public string Version { get; set; } = "v3"; public bool UseDarkTheme { get; set; } diff --git a/src/Smartstore.Core/Platform/Security/Filters/ValidateCaptchaAttribute.cs b/src/Smartstore.Core/Platform/Security/Filters/ValidateCaptchaAttribute.cs index 48f0939836..d1c61255f3 100644 --- a/src/Smartstore.Core/Platform/Security/Filters/ValidateCaptchaAttribute.cs +++ b/src/Smartstore.Core/Platform/Security/Filters/ValidateCaptchaAttribute.cs @@ -87,7 +87,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE { foreach (var message in result.Messages) { - var text = T("Common.CaptchaCheckFailed", message.Code).Value; + var text = T("Common.CaptchaCheckFailed", message.Code); if (message.Level == CaptchaValidationMessageLevel.Warning) { Logger.Warn(text); diff --git a/src/Smartstore.Core/Platform/Security/Providers/GoogleRecaptchaProvider.cs b/src/Smartstore.Core/Platform/Security/Providers/GoogleRecaptchaProvider.cs index 14249a4960..ea838e973b 100644 --- a/src/Smartstore.Core/Platform/Security/Providers/GoogleRecaptchaProvider.cs +++ b/src/Smartstore.Core/Platform/Security/Providers/GoogleRecaptchaProvider.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Newtonsoft.Json; using Smartstore.Core.Localization; +using Smartstore.Core.Web; using Smartstore.Core.Widgets; using Smartstore.Engine.Modularity; using Smartstore.Http; @@ -27,6 +28,7 @@ public GoogleRecaptchaProvider(IHttpClientFactory httpClientFactory, GoogleRecap } public Localizer T { get; set; } = NullLocalizer.Instance; + public ILogger Logger { get; set; } = NullLogger.Instance; public bool IsConfigured => _settings.SiteKey.HasValue() && _settings.SecretKey.HasValue(); @@ -53,8 +55,15 @@ public Task CreateWidgetAsync(CaptchaContext context) var theme = _settings.UseDarkTheme ? "dark" : "light"; var lang = context.Language?.UniqueSeoCode.EmptyNull().ToLowerInvariant(); var baseUrl = _settings.WidgetUrl.NullEmpty() ?? GoogleRecaptchaSettings.DefaultWidgetUrl; - var apiSrc = isV3 ? $"{baseUrl}?render={_settings.SiteKey}{(lang.HasValue() ? "&hl=" + lang : string.Empty)}" - : $"{baseUrl}?render=explicit{(lang.HasValue() ? "&hl=" + lang : string.Empty)}"; + + // 2. STRICT URL GENERATION: + // - v3 MUST use: render=SITE_KEY + // - v2 MUST use: render=explicit + // - If you mix this up, you get "Invalid Key Type" error. + var renderParam = IsV3 ? _settings.SiteKey : "explicit"; + var langParam = lang.HasValue() ? $"&hl={lang}" : string.Empty; + + var apiSrc = $"{baseUrl}?render={renderParam}{langParam}"; var assetBuilder = context.AssetBuilder; @@ -67,6 +76,8 @@ public Task CreateWidgetAsync(CaptchaContext context) script = $""; assetBuilder.AddHtmlContent("scripts", new HtmlString(script), scriptUrl); + // Determine if badge should be hidden + // Note: Hiding the badge is only legally allowed if you show the disclaimer manually. var isHiddenBadge = (!isV3 && _settings.Size.EqualsNoCase("invisible") && _settings.BadgePosition.EqualsNoCase("hide")) || (isV3 && _settings.HideBadgeV3); @@ -84,12 +95,6 @@ public Task CreateWidgetAsync(CaptchaContext context) element.Attributes["data-size"] = _settings.Size; element.Attributes["class"] = "g-recaptcha"; - if (isHiddenBadge) - { - // Invisible badge: we need to render the legally required notice here. - element.InnerHtml.AppendHtml(T("Admin.Configuration.Settings.GeneralCommon.GoogleRecaptcha.HiddenBadgeLegalNotice").Value); - } - content.AppendLine(element); // Provide minimal JSON config for the adapter @@ -102,6 +107,12 @@ public Task CreateWidgetAsync(CaptchaContext context) elementId }; content.AppendHtmlLine($""); + + if (isHiddenBadge) + { + // Invisible badge: we need to render the legally required notice here. + content.AppendHtmlLine(T("Admin.Configuration.Settings.GeneralCommon.GoogleRecaptcha.HiddenBadgeLegalNotice").Value); + } } else { @@ -138,18 +149,26 @@ public async Task ValidateAsync(CaptchaContext context, Guard.NotNull(context); var result = new CaptchaValidationResult(); + Exception verifyException = null; + // ════════════════════════════════════════════════════════════════════════ + // CONFIGURATION CHECK (Fail-Open: Customer might have wrong keys) + // ════════════════════════════════════════════════════════════════════════ if (!IsConfigured) { - result.Messages.Add(new CaptchaValidationMessage("configuration-missing", CaptchaValidationMessageLevel.Error)); - return result; + return LogFailOpen("configuration-missing", "Not configured"); } var token = context.HttpContext.Request.Form["g-recaptcha-response"].ToString(); + + // ════════════════════════════════════════════════════════════════════════ + // TOKEN PRESENCE CHECK (Fail-Closed: Bot exploit prevention!) + // ════════════════════════════════════════════════════════════════════════ if (token.IsEmpty()) { - result.Messages.Add(new CaptchaValidationMessage("missing-input-response", CaptchaValidationMessageLevel.Warning)); - return result; + Logger.Debug("reCAPTCHA validation failed: Token missing (likely bot or user skipped CAPTCHA). Blocking request."); + result.Messages.Add(new CaptchaValidationMessage("missing-input-response", CaptchaValidationMessageLevel.Error)); + return result; // ← Fail-Closed! } var client = _httpClientFactory.CreateClient(); @@ -157,52 +176,99 @@ public async Task ValidateAsync(CaptchaContext context, var verifyUrl = _settings.VerifyUrl.NullEmpty() ?? GoogleRecaptchaSettings.DefaultVerifyUrl; - using var content = new FormUrlEncodedContent( -[ - new KeyValuePair("secret", _settings.SecretKey), - new KeyValuePair("response", token), - new KeyValuePair("remoteip", context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty) + var postData = new List>( + [ + new("secret", _settings.SecretKey), + new("response", token) ]); + var ipAddress = context.HttpContext.Connection.RemoteIpAddress?.ToString(); + if (ipAddress.HasValue() && !context.HttpContext.Connection.IsLocal()) + { + // CRUCIAL: Add remoteip to the verification request + postData.Add(new("remoteip", ipAddress)); + } GoogleRecaptchaApiResponse payload = null; try { + using var content = new FormUrlEncodedContent(postData); using var response = await client.PostAsync(verifyUrl, content, cancelToken); var json = await response.Content.ReadAsStringAsync(cancelToken); payload = JsonConvert.DeserializeObject(json); } - catch + catch (Exception ex) { + verifyException = ex; } + // ════════════════════════════════════════════════════════════════════════ + // NETWORK/API FAILURE (Fail-Open: Google API down, not customer's fault) + // ════════════════════════════════════════════════════════════════════════ if (payload == null) { - result.Messages.Add(new CaptchaValidationMessage("unable-to-verify", CaptchaValidationMessageLevel.Error)); - return result; + return LogFailOpen("unable-to-verify", "Google API unreachable or returned empty payload"); } if (!payload.Success) { - if (payload.ErrorCodes != null) + var codes = payload.ErrorCodes?.Where(x => x.HasValue()).ToArray() ?? []; + + // Misconfiguration/system errors according to Google error code reference + var isMisconfiguration = + codes.Contains("missing-input-secret", StringComparer.OrdinalIgnoreCase) || + codes.Contains("invalid-input-secret", StringComparer.OrdinalIgnoreCase) || + codes.Contains("bad-request", StringComparer.OrdinalIgnoreCase); + + if (isMisconfiguration) + { + return LogFailOpen("misconfiguration", "Invalid secret key detected (check reCAPTCHA configuration)"); + } + + // ════════════════════════════════════════════════════════════════════════ + // INVALID TOKEN (Fail-Closed: Bot sent garbage/expired/stolen token) + // ════════════════════════════════════════════════════════════════════════ + // Bot or malicious user sent an invalid/expired/stolen token. Block! + foreach (var code in codes.Where(x => !x.Equals("missing-input-response", StringComparison.OrdinalIgnoreCase))) + { + var level = code.EqualsNoCase("invalid-input-response") || code.EqualsNoCase("timeout-or-duplicate") + ? CaptchaValidationMessageLevel.Warning + : CaptchaValidationMessageLevel.Error; + + result.Messages.Add(new CaptchaValidationMessage(code, level)); + } + + return result; // ← Fail-Closed! + } + + // ════════════════════════════════════════════════════════════════════════ + // HOSTNAME VALIDATION (Fail-Closed: Cross-domain token theft prevention) + // ════════════════════════════════════════════════════════════════════════ + if (payload.Hostname.HasValue()) + { + var expectedHost = context.HttpContext.Request.Host.Host; + if (!payload.Hostname.EqualsNoCase(expectedHost)) { - foreach (var code in payload.ErrorCodes.Where(x => x.HasValue() && x != "missing-input-response")) - { - if (code.IsEmpty()) - { - continue; - } - - var level = code == "invalid-input-response" - ? CaptchaValidationMessageLevel.Warning - : CaptchaValidationMessageLevel.Error; - - result.Messages.Add(new CaptchaValidationMessage(code, level)); - } + Logger.Debug("reCAPTCHA hostname mismatch: expected '{Expected}', got '{Actual}'. Potential cross-domain attack.", + expectedHost, payload.Hostname); + result.Messages.Add(new CaptchaValidationMessage("hostname-mismatch", CaptchaValidationMessageLevel.Error)); + return result; // ← Fail-Closed! } + } - return result; + // ════════════════════════════════════════════════════════════════════════ + // TIMESTAMP VALIDATION (Fail-Closed: Token replay attack prevention) + // ════════════════════════════════════════════════════════════════════════ + if (payload.ChallengeTs.HasValue) + { + var tokenAge = DateTime.UtcNow - payload.ChallengeTs.Value; + if (tokenAge > TimeSpan.FromMinutes(5)) + { + Logger.Debug("reCAPTCHA token expired: age {Age:0.0} minutes. Potential replay attack.", tokenAge.TotalMinutes); + result.Messages.Add(new CaptchaValidationMessage("token-expired", CaptchaValidationMessageLevel.Warning)); + return result; // ← Fail-Closed! + } } // v3 extra checks --- @@ -212,7 +278,7 @@ public async Task ValidateAsync(CaptchaContext context, if (score < _settings.ScoreThreshold) { result.Messages.Add(new CaptchaValidationMessage($"low-score({score:0.00})", CaptchaValidationMessageLevel.Warning)); - return result; + return result; // ← Fail-Closed! } // 2) Optional action comparison @@ -223,12 +289,20 @@ public async Task ValidateAsync(CaptchaContext context, if (!string.IsNullOrEmpty(expectedAction) && !expectedAction.EqualsNoCase(payload.Action)) { result.Messages.Add(new CaptchaValidationMessage("action-mismatch", CaptchaValidationMessageLevel.Warning)); - return result; + return result; // ← Fail-Closed! } } result.Success = true; return result; + + CaptchaValidationResult LogFailOpen(string code, string message) + { + Logger.Error(verifyException, $"reCAPTCHA verification failed: ${message}. Allowing request to pass (fail-open)."); + result.Success = true; + result.Messages.Add(new CaptchaValidationMessage(code, CaptchaValidationMessageLevel.Warning)); + return result; + } } private bool IsV3 => _settings.Version.Equals("v3", StringComparison.OrdinalIgnoreCase); diff --git a/src/Smartstore.Core/Platform/Web/UserAgent/useragent.yml b/src/Smartstore.Core/Platform/Web/UserAgent/useragent.yml index eeacdc8347..39041590d5 100644 --- a/src/Smartstore.Core/Platform/Web/UserAgent/useragent.yml +++ b/src/Smartstore.Core/Platform/Web/UserAgent/useragent.yml @@ -325,10 +325,15 @@ bots: # AI Bots - match: GPTBot + - match: ChatGPT + name: OpenAI ChatGPT - match: OAI-SearchBot - - match: OAI-Bot/ + - match: OAI-Bot name: OpenAI GPT Bot + - match: OpenAI + name: OpenAI + - match: DeepSeek name: DeepSeek Chat Bot @@ -345,9 +350,15 @@ bots: - match: PerplexityBot/ name: Perplexity Bot + - match: Perplexity + name: Perplexity + - match: anthropic-ai name: Anthropic AI + - match: Anthropic + name: Anthropic + - match: /[cC]laude[bB]ot/ name: Claude Bot @@ -359,12 +370,42 @@ bots: - match: InflectionPi - match: YouBot/ - - match: CCBot/ - match: ScaleAI-Bot/ - match: Diffbot/ name: $1 + + # Automation frameworks / agentic browsing + - match: Playwright + - match: Puppeteer + - match: Selenium + - match: WebDriver + - match: PhantomJS + name: $1 # Common tools, readers & apps + - match: PostmanRuntime + name: Postman + + - match: Insomnia + name: Insomnia + + - match: node-fetch + - match: undici + - match: libwww-perl + name: $1 + + - match: Go-http-client + name: Go http client + + - match: Java/ + name: Java + + - match: Apache-HttpClient + name: Apache HttpClient + + - match: Wget/ + name: Wget + - match: python/ name: Python diff --git a/src/Smartstore.Modules/Smartstore.Google.Analytics/Services/GoogleAnalyticsScriptHelper.cs b/src/Smartstore.Modules/Smartstore.Google.Analytics/Services/GoogleAnalyticsScriptHelper.cs index 47ba63874b..407f629679 100644 --- a/src/Smartstore.Modules/Smartstore.Google.Analytics/Services/GoogleAnalyticsScriptHelper.cs +++ b/src/Smartstore.Modules/Smartstore.Google.Analytics/Services/GoogleAnalyticsScriptHelper.cs @@ -245,7 +245,7 @@ private string GetShoppingCartItemsScript(List - /// Builds json properties for the category tree as defined by Google. + /// Builds JSON properties for the category tree as defined by Google. /// /// /// Category path in this form: @@ -258,19 +258,25 @@ private string GetShoppingCartItemsScript(List 1 ? i.ToString() : string.Empty)}: '{node.Text.EncodeJsStringUnquoted()}',"; - } + return string.Empty; } - return catScript; + var categoryItems = _breadcrumb.Trail + .Select((node, i) => new + { + Node = node, + Item = "item_category{0}: '{1}'".FormatInvariant( + i > 0 ? (i + 1).ToStringInvariant() : string.Empty, + node.Text.EncodeJsStringUnquoted()) + }) + .Where(x => x.Node.EntityName.EqualsNoCase(nameof(Category))) + .Select(x => x.Item) + .Take(5) + .ToList(); + + return string.Join(',', categoryItems); } diff --git a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Components/GmcConfigurationViewComponent.cs b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Components/GmcConfigurationViewComponent.cs index e2e95ec698..96cedff13f 100644 --- a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Components/GmcConfigurationViewComponent.cs +++ b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Components/GmcConfigurationViewComponent.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.EntityFrameworkCore; +using Smartstore.Core; +using Smartstore.Core.Data; using Smartstore.Google.MerchantCenter.Models; using Smartstore.Web.Components; @@ -10,11 +13,31 @@ namespace Smartstore.Google.MerchantCenter.Components /// public class GmcConfigurationViewComponent : SmartViewComponent { - public IViewComponentResult Invoke(object data) + private readonly SmartDbContext _db; + private readonly IWorkContext _workContext; + + public GmcConfigurationViewComponent(SmartDbContext db, IWorkContext workContext) + { + _db = db; + _workContext = workContext; + } + + public async Task InvokeAsync(object data) { var model = data as ProfileConfigurationModel; - ViewBag.LanguageSeoCode = Services.WorkContext.WorkingLanguage.UniqueSeoCode.EmptyNull().ToLower(); + var counts = await _db.Products + .Select(_ => new + { + NumProducts = _db.Products.Where(x => !x.IsSystemProduct).Count(), + NumGoogleProducts = _db.GoogleProducts().Count() + }) + .FirstAsync(); + + ViewBag.DefaultValueSettingsNote = T("Plugins.Feed.Froogle.DefaultValueSettingsNote", + counts.NumGoogleProducts.ToString("N0"), counts.NumProducts.ToString("N0")); + + ViewBag.LanguageSeoCode = _workContext.WorkingLanguage.UniqueSeoCode.EmptyNull().ToLower(); ViewBag.AvailableCategories = model.DefaultGoogleCategory.HasValue() ? new List { new() { Text = model.DefaultGoogleCategory, Value = model.DefaultGoogleCategory, Selected = true } } : null; diff --git a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.de-de.xml b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.de-de.xml index 1df6e0c2de..dddeb666df 100644 --- a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.de-de.xml +++ b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.de-de.xml @@ -37,7 +37,17 @@ Standardwerte - Standardwerte werden exportiert, wenn die entsprechende Information für das jeweilge Produkt nicht gepflegt wurde. Für einzelne Produkte können diese Werte sowohl in der Plugin-Konfiguration als auch direkt auf der Bearbeitungsseite des Produktes eingegeben werden. + + {0} von {1} Produkten sind GMC-Daten festgelegt.]]> + + + + Alle Produkte exportieren + + + Legt fest, ob alle Produkte exportiert werden (Standard). Falls deaktiviert, werden nur die Produkte exportiert, für die GMC-Daten festgelegt sind. Standard-Google-Kategorie fehlt diff --git a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.en-us.xml b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.en-us.xml index 946fe8486e..f88299bbfc 100644 --- a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.en-us.xml +++ b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Localization/resources.en-us.xml @@ -36,7 +36,16 @@ Default values - Default values will be exported if the information does not exist or is not available for a particular product. For a particular product, these values can be edited in the plugin configuration and the product edit page. + + {0} of {1} products.]]> + + + + Export all products + + + Specifies whether all products are exported (default). If disabled, only products for which GMC data is specified are exported. Default Google category is not set diff --git a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Models/ProfileConfigurationModel.cs b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Models/ProfileConfigurationModel.cs index bcee32f4bc..b3d6bcf132 100644 --- a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Models/ProfileConfigurationModel.cs +++ b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Models/ProfileConfigurationModel.cs @@ -2,50 +2,54 @@ { [CustomModelPart] [Serializable] + [LocalizedDisplay("Plugins.Feed.Froogle.")] public class ProfileConfigurationModel { - [LocalizedDisplay("Plugins.Feed.Froogle.DefaultGoogleCategory")] + [LocalizedDisplay("*DefaultGoogleCategory")] public string DefaultGoogleCategory { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.AdditionalImages")] + [LocalizedDisplay("*ExportAllProducts")] + public bool ExportAllProducts { get; set; } = true; + + [LocalizedDisplay("*AdditionalImages")] public bool AdditionalImages { get; set; } = true; - [LocalizedDisplay("Plugins.Feed.Froogle.Availability")] + [LocalizedDisplay("*Availability")] public string Availability { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.SpecialPrice")] + [LocalizedDisplay("*SpecialPrice")] public bool SpecialPrice { get; set; } = true; [UIHint("Gender")] - [LocalizedDisplay("Plugins.Feed.Froogle.Gender")] + [LocalizedDisplay("*Gender")] public string Gender { get; set; } [UIHint("AgeGroup")] - [LocalizedDisplay("Plugins.Feed.Froogle.AgeGroup")] + [LocalizedDisplay("*AgeGroup")] public string AgeGroup { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.Color")] + [LocalizedDisplay("*Color")] public string Color { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.Size")] + [LocalizedDisplay("*Size")] public string Size { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.Material")] + [LocalizedDisplay("*Material")] public string Material { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.Pattern")] + [LocalizedDisplay("*Pattern")] public string Pattern { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.ExpirationDays")] + [LocalizedDisplay("*ExpirationDays")] public int ExpirationDays { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.ExportShipping")] + [LocalizedDisplay("*ExportShipping")] public bool ExportShipping { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.ExportShippingTime")] + [LocalizedDisplay("*ExportShippingTime")] public bool ExportShippingTime { get; set; } - [LocalizedDisplay("Plugins.Feed.Froogle.ExportBasePrice")] + [LocalizedDisplay("*ExportBasePrice")] public bool ExportBasePrice { get; set; } } diff --git a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Providers/GmcXmlExportProvider.cs b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Providers/GmcXmlExportProvider.cs index 895c0a2d78..386f931d2c 100644 --- a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Providers/GmcXmlExportProvider.cs +++ b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Providers/GmcXmlExportProvider.cs @@ -115,8 +115,10 @@ protected override async Task ExportAsync(ExportExecuteContext context, Cancella Product entity = product.Entity; var googleProduct = googleProducts.Get(entity.Id); - if (googleProduct != null && !googleProduct.Export) + if ((googleProduct == null && !config.ExportAllProducts) || (googleProduct != null && !googleProduct.Export)) + { continue; + } writer.WriteStartElement("item"); diff --git a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Views/Shared/Components/GmcConfiguration/Default.cshtml b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Views/Shared/Components/GmcConfiguration/Default.cshtml index 083ef494d3..b1e1ac988b 100644 --- a/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Views/Shared/Components/GmcConfiguration/Default.cshtml +++ b/src/Smartstore.Modules/Smartstore.Google.MerchantCenter/Views/Shared/Components/GmcConfiguration/Default.cshtml @@ -47,6 +47,15 @@
@T("Plugins.Feed.Froogle.GeneralSettings")
+
+
+ +
+
+ + +
+
@@ -96,7 +105,7 @@
@T("Plugins.Feed.Froogle.DefaultValueSettings")
- @T("Plugins.Feed.Froogle.DefaultValueSettingsNote") + @Html.Raw(ViewBag.DefaultValueSettingsNote)
diff --git a/src/Smartstore.Modules/Smartstore.PayPal/Client/PayPalHttpClient.cs b/src/Smartstore.Modules/Smartstore.PayPal/Client/PayPalHttpClient.cs index 80918bfd54..3cee190d1f 100644 --- a/src/Smartstore.Modules/Smartstore.PayPal/Client/PayPalHttpClient.cs +++ b/src/Smartstore.Modules/Smartstore.PayPal/Client/PayPalHttpClient.cs @@ -599,7 +599,7 @@ private async Task> GetPurchaseUnitItemsAsync(ShoppingCar foreach (var item in model.Items) { - var cartItem = cart.Items.Where(x => x.Item.ProductId == item.ProductId).FirstOrDefault(); + var cartItem = cart.Items.FirstOrDefault(x => x.Item.Id == item.Id); var taxRate = await _taxService.GetTaxRateAsync(cartItem.Item.Product); var calculationContext = await _priceCalculationService.CreateCalculationContextAsync(cartItem, calculationOptions); var (unitPrice, subtotal) = await _priceCalculationService.CalculateSubtotalAsync(calculationContext); diff --git a/src/Smartstore.Modules/Smartstore.PayPal/Services/PayPalPaymentFilter.cs b/src/Smartstore.Modules/Smartstore.PayPal/Services/PayPalPaymentFilter.cs index e2c8369120..83f4bfe67e 100644 --- a/src/Smartstore.Modules/Smartstore.PayPal/Services/PayPalPaymentFilter.cs +++ b/src/Smartstore.Modules/Smartstore.PayPal/Services/PayPalPaymentFilter.cs @@ -10,6 +10,7 @@ namespace Smartstore.PayPal.Services /// Filters out: /// PayUponInvoice if the cart total is above upper limit. /// GooglePay if the request is local. + /// ApplePay if the request comes not from an Apple device. /// public partial class PayPalPaymentFilter : IPaymentMethodFilter { @@ -57,8 +58,26 @@ public async Task IsExcludedAsync(PaymentFilterRequest request) return true; } } + else if (request.PaymentProvider.Metadata.SystemName.EqualsNoCase(PayPalConstants.ApplePay)) + { + var httpContext = _services.WebHelper.HttpContext; + if (httpContext != null && !IsAppleDevice(httpContext.Request)) + { + return true; + } + } return false; } + + private static bool IsAppleDevice(HttpRequest request) + { + var userAgent = request.Headers.UserAgent.ToString(); + + return userAgent.Contains("iPhone", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("iPad", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("Macintosh", StringComparison.OrdinalIgnoreCase) + || userAgent.Contains("Mac OS", StringComparison.OrdinalIgnoreCase); + } } } \ No newline at end of file diff --git a/src/Smartstore.Modules/Smartstore.Stripe/Controllers/StripeController.cs b/src/Smartstore.Modules/Smartstore.Stripe/Controllers/StripeController.cs index 5f7790aaf2..b173823675 100644 --- a/src/Smartstore.Modules/Smartstore.Stripe/Controllers/StripeController.cs +++ b/src/Smartstore.Modules/Smartstore.Stripe/Controllers/StripeController.cs @@ -245,8 +245,10 @@ public async Task ConfirmOrder(string formData) var paymentIntentService = new PaymentIntentService(); PaymentIntent paymentIntent = null; - var shippingOption = customer.GenericAttributes.Get(SystemCustomerAttributeNames.SelectedShippingOption, store.Id); - var shipping = await GetShippingAddressAsync(customer, shippingOption.Name); + var shippingOption = customer.GenericAttributes.Get(SystemCustomerAttributeNames.SelectedShippingOption, store.Id); + var shipping = shippingOption != null + ? await GetShippingAddressAsync(customer, shippingOption.Name) + : null; if (state.PaymentIntent == null) { @@ -423,7 +425,7 @@ public async Task WebhookHandler() // Check if full order amount was captured. if (order.OrderTotal == convertedAmount) { - if (settings.CaptureMethod == "automatic" && order.CanMarkOrderAsAuthorized()) + if (settings.CaptureMethod == "automatic" && order.CanMarkOrderAsPaid()) { await _orderProcessingService.MarkOrderAsPaidAsync(order); } @@ -438,6 +440,11 @@ public async Task WebhookHandler() await _db.SaveChangesAsync(); } } + else + { + // The order may not have been created yet. Let Stripe send the hook again. + return StatusCode(500); + } } else if (stripeEvent.Type == EventTypes.ChargeRefunded) { diff --git a/src/Smartstore.Modules/Smartstore.Stripe/Providers/StripeElementsProvider.cs b/src/Smartstore.Modules/Smartstore.Stripe/Providers/StripeElementsProvider.cs index 47a9c2a87f..ba1482014c 100644 --- a/src/Smartstore.Modules/Smartstore.Stripe/Providers/StripeElementsProvider.cs +++ b/src/Smartstore.Modules/Smartstore.Stripe/Providers/StripeElementsProvider.cs @@ -121,15 +121,6 @@ public override Task ProcessPaymentAsync(ProcessPaymentReq return Task.FromResult(result); } - public override async Task PostProcessPaymentAsync(PostProcessPaymentRequest processPaymentRequest) - { - var settings = await _settingFactory.LoadSettingsAsync(processPaymentRequest.Order.StoreId); - processPaymentRequest.Order.PaymentStatus = settings.CaptureMethod == "automatic" - ? PaymentStatus.Paid - : PaymentStatus.Authorized; - return; - } - public override async Task RefundAsync(RefundPaymentRequest request) { var result = new RefundPaymentResult diff --git a/src/Smartstore.Web.Common/TagHelpers/Shared/DataTagHelper.cs b/src/Smartstore.Web.Common/TagHelpers/Shared/DataTagHelper.cs index d99dbfc0b7..8e3287ff75 100644 --- a/src/Smartstore.Web.Common/TagHelpers/Shared/DataTagHelper.cs +++ b/src/Smartstore.Web.Common/TagHelpers/Shared/DataTagHelper.cs @@ -26,7 +26,7 @@ protected override void ProcessCore(TagHelperContext context, TagHelperOutput ou output.Attributes.SetAttribute("value", _roundingHelper.Round(money).ToStringInvariant()); output.Attributes.SetAttribute("data-currency", money.Currency?.CurrencyCode); output.AppendCssClass("text-nowrap"); - output.Content.SetContent(money.ToString()); + output.Content.SetHtmlContent(money.ToString()); } } } diff --git a/src/Smartstore.Web/Areas/Admin/Controllers/OrderController.cs b/src/Smartstore.Web/Areas/Admin/Controllers/OrderController.cs index e1892188ff..4a7016fc14 100644 --- a/src/Smartstore.Web/Areas/Admin/Controllers/OrderController.cs +++ b/src/Smartstore.Web/Areas/Admin/Controllers/OrderController.cs @@ -209,7 +209,13 @@ public async Task OrderList(GridCommand command, OrderListModel m if (model.CustomerEmail.HasValue()) { - orderQuery = orderQuery.ApplySearchFilterFor(x => x.BillingAddress.Email, model.CustomerEmail); + orderQuery = orderQuery.ApplySearchFilter( + model.CustomerEmail, + LogicalRuleOperator.Or, + x => x.BillingAddress.Email, + x => x.Customer.Email, + x => x.ShippingAddress.Email); + } if (model.CustomerName.HasValue()) { diff --git a/src/Smartstore.Web/Areas/Admin/Controllers/ProductController.Attributes.cs b/src/Smartstore.Web/Areas/Admin/Controllers/ProductController.Attributes.cs index f443a0ed35..5fcf47c20d 100644 --- a/src/Smartstore.Web/Areas/Admin/Controllers/ProductController.Attributes.cs +++ b/src/Smartstore.Web/Areas/Admin/Controllers/ProductController.Attributes.cs @@ -68,7 +68,8 @@ public async Task ProductSpecAttrList(GridCommand command, int pr [Permission(Permissions.Catalog.Product.EditAttribute)] public async Task ProductSpecificationAttributeAdd( int attributeId, - string attributeOption /*ID or name of new option*/, + int optionId, + string optionValue, bool? allowFiltering, bool? showOnProductPage, int displayOrder, @@ -77,28 +78,36 @@ public async Task ProductSpecificationAttributeAdd( var success = false; var message = string.Empty; - if (attributeId != 0 && attributeOption.HasValue()) + if (attributeId != 0 && optionValue.HasValue()) { try { - var optionId = attributeOption.ToInt(); if (optionId == 0 && await _db.SpecificationAttributes.AnyAsync(x => x.Id == attributeId)) { - var maxDisplayOrder = (await _db.SpecificationAttributeOptions - .Where(x => x.SpecificationAttributeId == attributeId) - .MaxAsync(x => (int?)x.DisplayOrder)) ?? 0; + // Handle case where the user enters a new option value multiple times without reloading the edit page. + optionId = await _db.SpecificationAttributeOptions + .Where(x => x.SpecificationAttributeId == attributeId && x.Name == optionValue) + .Select(x => x.Id) + .FirstOrDefaultAsync(); - var newOption = new SpecificationAttributeOption + if (optionId == 0) { - SpecificationAttributeId = attributeId, - Name = attributeOption, - DisplayOrder = ++maxDisplayOrder - }; - - _db.SpecificationAttributeOptions.Add(newOption); - await _db.SaveChangesAsync(); - - optionId = newOption.Id; + var maxDisplayOrder = (await _db.SpecificationAttributeOptions + .Where(x => x.SpecificationAttributeId == attributeId) + .MaxAsync(x => (int?)x.DisplayOrder)) ?? 0; + + var newOption = new SpecificationAttributeOption + { + SpecificationAttributeId = attributeId, + Name = optionValue, + DisplayOrder = ++maxDisplayOrder + }; + + _db.SpecificationAttributeOptions.Add(newOption); + await _db.SaveChangesAsync(); + + optionId = newOption.Id; + } } _db.ProductSpecificationAttributes.Add(new() diff --git a/src/Smartstore.Web/Areas/Admin/Views/Category/_CreateOrUpdate.cshtml b/src/Smartstore.Web/Areas/Admin/Views/Category/_CreateOrUpdate.cshtml index 3f181887e5..ade25de83e 100644 --- a/src/Smartstore.Web/Areas/Admin/Views/Category/_CreateOrUpdate.cshtml +++ b/src/Smartstore.Web/Areas/Admin/Views/Category/_CreateOrUpdate.cshtml @@ -122,7 +122,7 @@
+ save-url="@(Url.PatchLocalizedEntity(nameof(Category.Description), Model.Id, Model.Locales[item].LanguageId))" />
@@ -132,7 +132,7 @@
+ save-url="@(Url.PatchLocalizedEntity(nameof(Category.BottomDescription), Model.Id, Model.Locales[item].LanguageId))" />
diff --git a/src/Smartstore.Web/Areas/Admin/Views/Order/_Edit.BillingAndShipment.cshtml b/src/Smartstore.Web/Areas/Admin/Views/Order/_Edit.BillingAndShipment.cshtml index ac30cc0d9b..965e7598b7 100644 --- a/src/Smartstore.Web/Areas/Admin/Views/Order/_Edit.BillingAndShipment.cshtml +++ b/src/Smartstore.Web/Areas/Admin/Views/Order/_Edit.BillingAndShipment.cshtml @@ -24,13 +24,13 @@
@Html.Raw(billTo.FormattedAddress)
-