Zoel-Manchon/elitewear-xi
GitHub: Zoel-Manchon/elitewear-xi
一个以应用安全为核心设计理念的全功能复古球衣电商作品集项目,涵盖购物、支付、管理和 API 完整流程。
Stars: 0 | Forks: 0
Elitewear XI
复古足球球衣电商平台。
Laravel 13 · MySQL · 基于 Sass 的 Bootstrap 5 · PayPal sandbox · 版本化 API REST
**目录** — 可组合的过滤器,带有分面计数 | 
**详情页** — 图库、尺码和真实库存 | | 
**图库** — 使用 PhotoSwipe 实现全屏放大 | 
**购物车** — 带有免邮进度条的侧边抽屉 | | 
**结算** — 优惠码应用于订单摘要 | ### 管理 | | | |---|---| | 
**仪表盘** — 收入、订单和低库存 | 
**目录** — 尺码、库存和图片 | | 
**审计** — 谁在何时通过哪个 IP 修改了什么 | 
**API v1** — JSON 格式的公开目录 | ## 启动 ### 使用 Docker ``` cp .env.example .env npm ci npm run build docker compose down -v --remove-orphans # primera instalación o reinicio total docker compose build --no-cache app queue docker compose up -d mysql redis app web docker compose exec app php artisan key:generate --force docker compose exec app php artisan migrate:fresh --seed --force docker compose exec app php artisan optimize:clear docker compose up -d queue docker compose exec app php artisan catalog:doctor ``` Compose 的开发配置使用内部且可复现的 MySQL 账户(`elitewear` / `secret`)。它不依赖于你为 Windows 上的 MySQL 配置的用户名或密码。`app` 镜像包含了 Composer,因此可以在容器内运行与 CI 相同的检查: ``` docker compose exec app composer validate --strict --no-check-publish docker compose exec app vendor/bin/phpstan analyse --no-progress docker compose exec app vendor/bin/pint --test docker compose exec app php artisan test ``` 要包括数据库在内从头完全重建: ``` sh docker/reset.sh ``` 在 PowerShell 中: ``` .\docker\reset.ps1 ``` 要在推送前运行等同于 CI 的检查: ``` .\docker\verify.ps1 ``` 访问 `http://localhost:8000`。 该镜像自带**编译好的 GD**,这是 Intervention Image 重新编码从第三方下载的图片所必需的。这是在这里使用 Docker 的主要原因:环境不再依赖于每个人的 PHP 配置方式。 ### 不使用 Docker 需要 PHP 8.3+,并带有 `gd`、`pdo_mysql`、`mbstring`、`intl`、`zip` 和 `exif` 扩展。 ``` composer install npm install && npm run build cp .env.example .env php artisan key:generate php artisan migrate --seed php artisan storage:link php artisan serve ``` ### 从 API 填充目录 ``` php artisan catalog:sportsdb-full --all-countries --max-countries=15 php artisan kits:gallery --max=2 # segunda equipación por producto php artisan teams:media --limit=40 # banner y fanart de cada club php artisan players:import --max=6 # retratos de jugadores ``` ### 如果无法启动 | 症状 | 原因 | 解决方法 | |---|---|---| | 页面没有样式 | 之前的 `npm run dev` 留下了 `public/hot` | `rm -f public/hot && npm run build` | | 目录为空 | 数据填充未完成,或产品未发布 | `php artisan catalog:doctor` 可进行区分,并使用 `--publish` 进行修复 | | 导入时图片大小为 0 | 缺少 `gd`,或 Intervention Image 版本不匹配 | Docker 镜像自带 `gd`;错误现在会显示在已忽略的列中 | | 代码修改未生效 | Dockerfile 将代码复制到了镜像中 | 代码目录已挂载到 `docker-compose.yml` 中;如果你修改了 `composer.json`,则确实需要执行 `docker compose build` | | 连接 MySQL 时出现 `Connection refused` | `.env` 指向了宿主机的 MySQL | `docker-compose.yml` 覆盖了 `DB_HOST` 和 `DB_PORT`;请检查用户名和密码 | ## 架构 ``` graph TD subgraph Entrada WEB[Rutas web · Blade] API[API v1 · Sanctum] HOOK[Webhook de PayPal] CLI[Comandos de importación] end subgraph Aplicación CTRL[Controladores
finos] REQ[Form Requests
validación] ACT[Actions
casos de uso] RES[Resources
serialización] end subgraph Dominio MOD[Modelos Eloquent] ENUM[Enums
OrderStatus · ShirtSize · KitType] MONEY[Money
value object] end subgraph Externo DB[(MySQL)] DISK[Disco público] PP[PayPal Orders v2] SDB[TheSportsDB] WM[Wikimedia Commons] end WEB --> CTRL API --> CTRL HOOK --> CTRL CLI --> ACT CTRL --> REQ CTRL --> ACT CTRL --> RES ACT --> MOD MOD --> ENUM MOD --> MONEY MOD --> DB ACT --> DISK ACT --> PP CLI --> SDB CLI --> WM style ACT fill:#d6b36a,color:#12201a style PP fill:#1a2f27,color:#f4f1e8 ``` 控制器不包含业务逻辑:它们通过 Form Request 进行验证,委托给 Action 处理,并使用 Resource 进行序列化。项目中唯一的接口是 `PaymentGateway`,它的存在是为了在测试中能用替身替换掉 PayPal。 **没有在 Eloquent 之上封装 Repository。** Eloquent 本身就是 Repository;包装它只会增加一层毫无实际解耦作用的代码。 ## 数据模型 ``` erDiagram TEAMS ||--o{ PRODUCTS : "fabrica" TEAMS ||--o{ TEAM_EQUIPMENTS : "equipaciones" TEAMS ||--o{ PLAYERS : "plantilla" PRODUCTS ||--o{ PRODUCT_VARIANTS : "tallas" PRODUCTS ||--o{ PRODUCT_IMAGES : "galería" CATEGORIES }o--o{ PRODUCTS : "clasifica" USERS ||--o{ ADDRESSES : "tiene" USERS ||--o| CARTS : "posee" CARTS ||--o{ CART_ITEMS : "contiene" PRODUCT_VARIANTS ||--o{ CART_ITEMS : "referencia" USERS ||--o{ ORDERS : "hace" ORDERS ||--o{ ORDER_ITEMS : "congela" ORDERS ||--o{ PAYMENTS : "registra" COUPONS ||--o{ ORDERS : "descuenta" PRODUCT_VARIANTS |o--o{ ORDER_ITEMS : "referencia débil" USERS ||--o{ REVIEWS : "escribe" USERS ||--o{ WISHLIST_ITEMS : "guarda" PRODUCT_VARIANTS ||--o{ STOCK_ALERTS : "avisa" PRODUCTS { bigint base_price_cents "entero, nunca float" string season "1994-95" timestamp published_at "null = borrador" } PRODUCT_VARIANTS { string size "unidad de inventario" int stock int price_delta_cents } CART_ITEMS { int quantity "sin precio: se recalcula" } ORDER_ITEMS { string product_name "snapshot" bigint unit_price_cents "snapshot" } PAYMENTS { string provider_order_id "unique = idempotencia" } ``` 四条规则支撑着其他一切: | 规则 | 原因 | |---|---| | 金额以分为单位的整数表示 | `float` 在累加时会丢失精度 | | `order_items` 保存快照 | 如果目录发生变化,历史订单不能随之改变 | | `cart_items` **不**保存价格 | 购物车是意向;订单是契约 | | 购物车引用 *变体* | 尺码是库存单位,而不是一个普通属性 | ## 订单生命周期 ``` stateDiagram-v2 [*] --> pending: PlaceOrder
(stock bloqueado y descontado) pending --> paid: captura de PayPal
o webhook pending --> cancelled: el cliente abandona paid --> shipped paid --> refunded paid --> cancelled shipped --> delivered shipped --> refunded delivered --> refunded cancelled --> [*] refunded --> [*] delivered --> [*] ``` 状态机存在于 `OrderStatus::canTransitionTo()` 中,而不是在控制器或表单中。一个带有任意状态的手动 `POST` 请求会像界面中的点击一样被拒绝。 ## 结算与支付 ``` sequenceDiagram autonumber actor C as Cliente participant L as Laravel participant DB as MySQL participant PP as PayPal C->>L: POST /checkout (dirección) L->>DB: BEGIN L->>DB: SELECT ... FOR UPDATE (variantes y cupón) Note over L,DB: El bloqueo serializa a dos compradores
que van a por la última talla M alt Stock insuficiente L->>DB: ROLLBACK L-->>C: "Solo quedan N unidades" else Hay stock L->>DB: descuenta stock, canjea cupón, crea pedido y snapshot L->>DB: COMMIT L-->>C: página de pago end C->>L: POST /pagos/{pedido}/paypal L->>PP: crear orden (importe tomado del PEDIDO) PP-->>L: id de orden C->>PP: aprueba en la ventana de PayPal C->>L: POST .../capturar L->>PP: capturar PP-->>L: id de captura + importe Note over L: Contrasta el importe capturado
contra el total del pedido L->>DB: pedido = paid L-->>C: confirmación PP->>L: webhook PAYMENT.CAPTURE.COMPLETED Note over L: Verifica la firma.
Si ya está pagado, no hace nada:
idempotente por diseño ``` 浏览器永远不会看到或发送金额。用户唯一能操纵的是 PayPal 的订单标识符,服务器会在确认收款之前将其与系统内的订单进行比对。 ## 购物车 ``` flowchart LR A[Visitante] -->|añade| B{¿Sesión iniciada?} B -->|No| C[Carrito de invitado
token en la SESIÓN] B -->|Sí| D[Carrito del usuario] C -->|inicia sesión| E[MergeGuestCart] E --> D D --> F[Cajón lateral
fetch sin recargar] F --> G[Checkout] style C fill:#1a2f27,color:#f4f1e8 style E fill:#d6b36a,color:#12201a ``` 游客购物车的 token 保存在 session 中,**绝对不**放在 URL 或自定义 cookie 中。如果客户端可以自行选择,修改这个值就可以读取或清空他人的购物车:这是一个典型的 IDOR,也是手写购物车系统中最常见的漏洞。 ## 从外部 API 导入 目录的数据来源于 **TheSportsDB**(球衣、队徽、模板)和 **Wikimedia Commons**(复古档案)。这是系统中引入不受我们控制内容的入口。 ``` flowchart TD A[URL de la API externa] --> B{¿Dominio permitido?} B -->|No| X1[Rechazada] B -->|Sí| C[Descarga con límite de tamaño] C --> D{¿getimagesizefromstring
reconoce los bytes?} D -->|No| X2[Rechazada] D -->|Sí| E{¿PNG, JPEG o WEBP?
¿Dimensiones razonables?} E -->|No| X3[Rechazada] E -->|Sí| F[Nombre generado por nosotros
id externo saneado] F --> G[Reencodeado a WEBP] G --> H[(Disco público)] style X1 fill:#c4331f,color:#fff style X2 fill:#c4331f,color:#fff style X3 fill:#c4331f,color:#fff style G fill:#d6b36a,color:#12201a ``` 一切都通过 `RemoteImageStore` 处理,其原则非常明确:**通过 HTTP 获取的图片并不比用户上传的图片更值得信任**。 除了字节大小限制外,还有像素限制:一个 30 KB 的 PNG 可能会解压缩成 40,000 × 40,000 的尺寸,并在重新编码时耗尽内存。 ## 补货通知 ``` sequenceDiagram actor V as Visitante participant L as Laravel participant DB as MySQL actor A as Admin participant Q as Cola V->>L: POST /avisos (talla + correo) L->>DB: firstOrCreate(variante, correo) L-->>V: "Te escribiremos cuando vuelva" Note over L,V: Respuesta idéntica siempre.
Un mensaje distinto convertiría
esto en un comprobador de cuentas. A->>L: repone stock desde el panel L->>DB: UPDATE stock 0 -> N Note over L: El observador comprueba el valor
ANTERIOR: solo dispara en la
transición 0 -> positivo L->>Q: encola BackInStock Q-->>V: correo con enlace firmado de baja ``` ## 安全性 | 控制 | 位置 | 防范目标 | |---|---|---| | 购物车归属权检查 | `CartController::authorizeItem()` | 针对购物车条目的 IDOR | | session 中的游客 token | `CartResolver` | 通过参数篡改窃取购物车 | | 排序白名单 | `CatalogController::SORTS` | 通过 `orderBy()` 进行的 SQL 注入 | | 转义 `%` 和 `_` | 目录和搜索 | 使用 `?q=%` 转储目录 | | 针对 `published_at` 的 `abort_unless` | `CatalogController::show()` | 通过猜测 URL 访问草稿 | | `is_admin` 不在 `#[Fillable]` 中 | `User` | 通过 `POST /register` 进行权限提升 | | 在 `/admin` 返回 404 而非 403 | `EnsureUserIsAdmin` | 面板枚举 | | enum 中的状态机 | `OrderStatus` | 通过手动 POST 绕过流程 | | 订单上的 `lockForUpdate()` | `PlaceOrder` | 并发导致的超卖 | | 优惠码上的 `lockForUpdate()` | `PlaceOrder` | 一次性优惠码被多次兑换 | | 折扣限制在小计范围内 | `Coupon::discountFor()` | 负数总计 | | 统一的无效优惠码提示信息 | `CouponController` | 暴力破解优惠码 | | 从订单中获取金额 | `PayPalGateway` | 客户端篡改价格 | | 比对已捕获金额 | `PaymentController` | 捕获低于订单总额的金额 | | `provider_order_id` 上的 `unique` | `payments` 表 | 重复的 Webhooks | | Webhook 签名验证 | `PaymentController` | 伪造的 Webhooks | | 净化后的文件名 | `RemoteImageStore` | 来自 API 响应的路径遍历 | | 校验真实字节流 | `RemoteImageStore` | 远程服务器伪造的 `Content-Type` | | 重新编码为 WEBP | `RemoteImageStore` | 追加在有效图片头之后的恶意 payload | | 像素数量限制 | `RemoteImageStore` | 解压缩时导致内存耗尽 | | 禁止上传 SVG | `ProductRequest` | 通过同源向量触发的 XSS | | 带有 nonce 且禁用 `unsafe-inline` 的 CSP | `SecurityHeaders` | 存储型 XSS | | CSP 中的 `report-uri` | `SecurityHeaders` | 无遥测的策略 | | 在 `innerHTML` 之前使用 `escapeHtml()` | `http.js` | 来自目录的 XSS | | 转义 JSON-LD 中的标签 | `shop/show.blade.php` | 源于 TheSportsDB 的 XSS | | 校验 YouTube ID | `Team::youtubeId()` | 来自第三方 URL 的注入 | | `Password::uncompromised()` | `RegisterController` | 已泄露的凭据 | | 基于邮箱 + IP 的 Rate limiting | `TokenController` | 针对 API 的暴力破解 | | 统一的登录错误提示 | `TokenController` | 用户枚举 | | 显示可用性而非具体库存 | `ProductVariantResource` | 业务信息泄露 | | 通知返回完全相同的响应 | `StockAlertController` | 通过邮件探测账号 | | 退订使用签名 URL | `BackInStock` | 第三方伪造的退订请求 | | 审计日志 | `Auditable` | 无法识别身份的更改 | | 从审计记录中排除机密信息 | `Auditable::$auditExclude` | 审计系统自身导致的数据泄露 | CSP 之所以有效,是因为**所有视图中都没有内联 JavaScript**。 图库、购物车、搜索和过滤器都位于由 Vite 导入的模块中。后期再进行这种重构成本很高;从一开始就这么做则是免费的。 ## 已发现并修复的漏洞 这些都是能让人学到东西的内容,包含了真正的起因而不仅仅是表面症状。 **组合使用两个过滤器时无法返回任何结果。** 条件原本是 `$except !== 'tipo' && ($filters['tipo'] ?? null)`。在 PHP 中,`&&` **总是返回布尔值**,因此 `when()` 的闭包接收到的是 `true` 而不是 slug,查询实际上在比较 `slug = 1`。对于 `decada`,`substr(true, 0, 3)` 的结果是 `'1'`,于是 `season LIKE '1%'` 匹配了所有 1970-1999 年的范围,这就是为什么它看起来似乎部分有效。它躲过了三次代码审查,因为没有测试去检查过滤器是否*返回了*正确的结果,仅仅检查了页面是否能够加载。如今 `CatalogFilterTest` 通过八个测试用例涵盖了这一点。 **来自 API 响应的路径遍历。** 球衣的外部标识符被原封不动地拼接到了文件路径中。如果响应中带有 `../` 的 ID,就会写入到预期目录之外的位置。这是由第三方控制的数据,而不是用户输入:正是那种容易被忘记验证的输入。 **通过 JSON-LD 实现的存储型 XSS。** 结构化数据通过 `JSON_UNESCAPED_SLASHES` 序列化并嵌入到 ``(这些名称正来自于 TheSportsDB),就会提前关闭标签块并执行其后的任何内容。 **三次隐蔽的批量赋值漏洞。** 位于 `$fillable` 之外的 `notified_at`、`is_approved` 和 `redemptions_count` 会导致 `update()` 静默丢弃这些字段而不报错。症状总是相同的:“这东西没保存上,但也没报任何错”。当 `update()` 没有起作用时,`$fillable` 是你首先需要检查的地方。 **本该是文件的地方却是个目录。** `phpstan.neon` 引入了 `phpstan-baseline.neon`,但在代码仓库中这个名字对应的是一个空目录。PHPStan 由于无法读取 `includes:` 指定的文件而中止,导致静态分析任务失败,却没有产生任何分析错误 —— 报错信息完全没有提及代码本身。压缩包和 Git 不会保留空文件,一个偶然的 `mkdir` 就会把它变成文件夹。 **针对真实数据库运行的测试。** `phpunit.xml` 声明了 `DB_CONNECTION=sqlite`,但在 Docker 环境下,`env_file` 将 `.env` 作为进程变量注入,而 PHPUnit 并不会覆盖它们。测试套件对 MySQL 执行了 `migrate:fresh` 并**删除了开发环境的目录数据库**。这与测试中 CSRF 处于激活状态以及 DNS 验证发起真实查询的原因相同:看似无关的症状,仅仅是因为同一个配置引起的。现在的 `TestCase::refreshApplication()` 会在 `RefreshDatabase` 运行之前强制使用内存级别的 SQLite。 **目录的级联停用。** 如果 API 没有返回球衣数据,激活的 ID 列表就会变为空,过滤器将不再适用,`update` 操作就会波及该球队下的*所有*产品。第三方的一个临时故障就会在没有任何提示的情况下下架整个目录。 ## API 完整文档请见 [`docs/openapi.yaml`](docs/openapi.yaml)。 ``` # 公开目录 curl http://localhost:8000/api/v1/products?team=afc-ajax # Token curl -X POST http://localhost:8000/api/v1/tokens \ -H 'Content-Type: application/json' \ -d '{"email":"tu@correo.com","password":"...","device_name":"cli"}' # 订单 curl http://localhost:8000/api/v1/orders -H 'Authorization: Bearer
标签:ffuf, Laravel, PayPal支付, RESTful API, Syscall, Web开发, 复古球衣, 提示词优化, 搜索引擎查询, 电商平台, 请求拦截