docs(codebase): mapa kodu wygenerowana przez /paul:map-codebase
7 dokumentów w .paul/codebase/ — overview, stack, architecture, conventions, testing, integrations, concerns (CRITICAL→LOW). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
33
.paul/codebase/README.md
Normal file
33
.paul/codebase/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Codebase Map — cmsPRO
|
||||||
|
|
||||||
|
> Generated: 2026-04-26 | Auto-generated by /paul:map-codebase
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|---------|
|
||||||
|
| [overview.md](overview.md) | Project summary, modules, entry points, refactoring status |
|
||||||
|
| [stack.md](stack.md) | PHP runtime, database, frontend libs, server config, external services |
|
||||||
|
| [architecture.md](architecture.md) | Directory map, patterns, routing, caching, namespaces |
|
||||||
|
| [conventions.md](conventions.md) | Naming, class patterns, PHPDoc, return types, DB access |
|
||||||
|
| [testing.md](testing.md) | PHPUnit setup, test structure, stubs, adding new tests |
|
||||||
|
| [integrations.md](integrations.md) | Email, geolocation, analytics, update server, file manager |
|
||||||
|
| [concerns.md](concerns.md) | Technical debt prioritized CRITICAL → HIGH → MEDIUM → LOW |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
- **Architecture**: Controls → (deprecated) Factories → Domain Repositories → Medoo/MySQL
|
||||||
|
- **New code goes in**: `autoload/Domain/{Entity}/{Entity}Repository.php`
|
||||||
|
- **Tests go in**: `tests/Unit/Domain/{Entity}/{Entity}RepositoryTest.php`
|
||||||
|
- **Global helper**: `\S::method()` (legacy) or `\Shared\Helpers\Helpers::method()` (preferred)
|
||||||
|
- **Templates**: `templates/{module}/template.php` (user override: `templates_user/`)
|
||||||
|
- **CSRF**: `\Shared\Security\CsrfToken::getToken()` / `::validate($token)`
|
||||||
|
- **Cache**: `\Shared\Cache\CacheHandler::store($key, $data, $ttl)` / `::fetch($key)`
|
||||||
|
|
||||||
|
## Top Issues to Fix
|
||||||
|
|
||||||
|
1. **CRITICAL**: `unserialize()` on cookie — `admin/ajax/pages.php:36,49`
|
||||||
|
2. **CRITICAL**: Path traversal in updates — `autoload/admin/factory/class.Update.php:76-80`
|
||||||
|
3. **HIGH**: Missing input validation everywhere
|
||||||
|
4. **HIGH**: Password hash in auto-login cookie — `admin/index.php:59-61`
|
||||||
|
5. **MEDIUM**: God class Helpers.php (1220 lines) — needs splitting
|
||||||
160
.paul/codebase/architecture.md
Normal file
160
.paul/codebase/architecture.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
> Generated: 2026-04-26
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
cmsPRO uses a **3-layer architecture** with clean admin/frontend separation:
|
||||||
|
|
||||||
|
```
|
||||||
|
Request
|
||||||
|
↓
|
||||||
|
Controls (admin\controls\ or front\controls\) ← request handling
|
||||||
|
↓
|
||||||
|
Factories (admin\factory\ or front\factory\) ← DEPRECATED wrappers → will be removed
|
||||||
|
↓
|
||||||
|
Domain Repositories (Domain\*\*Repository) ← data access (new pattern)
|
||||||
|
↓
|
||||||
|
Medoo ORM → MySQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Views are rendered through `admin\view\*` / `front\view\*` → `Shared\Tpl\Tpl` → Savant3 templates.
|
||||||
|
|
||||||
|
## Directory Map
|
||||||
|
|
||||||
|
```
|
||||||
|
autoload/
|
||||||
|
├── autoloader.php Hybrid PSR-4 + legacy autoloader
|
||||||
|
├── class.S.php Global helper facade (deprecated wrapper)
|
||||||
|
├── class.Article.php Legacy entity (ArrayAccess)
|
||||||
|
├── class.Page.php Legacy entity
|
||||||
|
├── class.Scontainer.php Legacy entity
|
||||||
|
├── class.Cache.php / class.Cron.php / class.Image.php / class.Html.php
|
||||||
|
│
|
||||||
|
├── Domain/ NEW — Repository pattern, DDD
|
||||||
|
│ ├── Articles/ArticlesRepository.php (648 lines)
|
||||||
|
│ ├── Authors/AuthorsRepository.php (156 lines)
|
||||||
|
│ ├── Banners/BannersRepository.php (148 lines)
|
||||||
|
│ ├── Languages/LanguagesRepository.php (213 lines)
|
||||||
|
│ ├── Layouts/LayoutsRepository.php (123 lines)
|
||||||
|
│ ├── Newsletter/NewsletterRepository.php (281 lines)
|
||||||
|
│ ├── Pages/PagesRepository.php (451 lines)
|
||||||
|
│ ├── Scontainers/ScontainersRepository.php (110 lines)
|
||||||
|
│ ├── Settings/SettingsRepository.php (73 lines)
|
||||||
|
│ └── User/UserRepository.php (235 lines)
|
||||||
|
│
|
||||||
|
├── Shared/ Cross-cutting services
|
||||||
|
│ ├── Helpers/Helpers.php God class — 1220 lines (⚠ needs splitting)
|
||||||
|
│ ├── Tpl/Tpl.php Template renderer (checks templates_user/ first)
|
||||||
|
│ ├── Email/Email.php Email service (wraps PHPMailer)
|
||||||
|
│ ├── Cache/CacheHandler.php File-based cache (gzdeflate, TTL)
|
||||||
|
│ ├── Security/CsrfToken.php CSRF token generation + validation
|
||||||
|
│ ├── Html/Html.php HTML form element builder
|
||||||
|
│ └── Image/ImageManipulator.php Image processing
|
||||||
|
│
|
||||||
|
├── admin/
|
||||||
|
│ ├── class.Site.php Admin routing + 2FA
|
||||||
|
│ ├── controls/class.*.php 18 request handler classes (static methods)
|
||||||
|
│ ├── factory/class.*.php 18 @deprecated wrappers
|
||||||
|
│ └── view/class.*.php 14 template renderer classes
|
||||||
|
│
|
||||||
|
└── front/
|
||||||
|
├── controls/class.Site.php Main frontend router
|
||||||
|
├── controls/class.*.php 4 frontend controllers
|
||||||
|
├── factory/class.*.php 17 frontend factories
|
||||||
|
└── view/class.*.php View renderers
|
||||||
|
|
||||||
|
admin/
|
||||||
|
├── index.php Admin entry point (IP check, session, routing)
|
||||||
|
├── ajax.php Admin AJAX dispatcher → admin/ajax/*.php
|
||||||
|
└── templates/ Admin Savant3 templates (per module)
|
||||||
|
|
||||||
|
templates/ Frontend Savant3 templates
|
||||||
|
templates_user/ User-overridable template overrides
|
||||||
|
plugins/
|
||||||
|
├── special-actions.php Hook: pre-routing
|
||||||
|
├── special-actions-middle.php Hook: mid-request
|
||||||
|
└── special-actions-end.php Hook: post-rendering
|
||||||
|
```
|
||||||
|
|
||||||
|
## Namespace Convention
|
||||||
|
|
||||||
|
| Namespace | Path | Convention |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| `admin\controls\` | `autoload/admin/controls/class.*.php` | Legacy lowercase |
|
||||||
|
| `admin\factory\` | `autoload/admin/factory/class.*.php` | Legacy, @deprecated |
|
||||||
|
| `admin\view\` | `autoload/admin/view/class.*.php` | Legacy lowercase |
|
||||||
|
| `front\controls\` | `autoload/front/controls/class.*.php` | Legacy lowercase |
|
||||||
|
| `front\factory\` | `autoload/front/factory/class.*.php` | Legacy lowercase |
|
||||||
|
| `Domain\*\` | `autoload/Domain/*/ClassName.php` | PSR-4 PascalCase |
|
||||||
|
| `Shared\*\` | `autoload/Shared/*/ClassName.php` | PSR-4 PascalCase |
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
### Repository Pattern (Domain layer)
|
||||||
|
```php
|
||||||
|
class ArticlesRepository {
|
||||||
|
public function __construct($db) { $this->db = $db; }
|
||||||
|
public function find(int $id): ?array { ... }
|
||||||
|
public function save(...): int { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Factory Wrapper (deprecated bridge)
|
||||||
|
```php
|
||||||
|
/** @deprecated Używaj Domain\Articles\ArticlesRepository przez DI */
|
||||||
|
class Articles {
|
||||||
|
private static function repo(): ArticlesRepository {
|
||||||
|
global $mdb;
|
||||||
|
return new ArticlesRepository($mdb);
|
||||||
|
}
|
||||||
|
public static function article_delete($id): bool {
|
||||||
|
return self::repo()->deleteArticle($id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controls (request handler)
|
||||||
|
```php
|
||||||
|
class Articles {
|
||||||
|
public static function article_delete() {
|
||||||
|
global $user;
|
||||||
|
if (!admin\factory\Users::check_privileges('articles', $user['id']))
|
||||||
|
return \S::alert('Brak uprawnień');
|
||||||
|
// delegate to factory → repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Global Helper Facade
|
||||||
|
```php
|
||||||
|
// class.S.php — calls Shared\Helpers\Helpers via __callStatic
|
||||||
|
\S::get('param') // → Helpers::get()
|
||||||
|
\S::delete_cache() // → Helpers::delete_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Routing
|
||||||
|
|
||||||
|
`GET /admin/?a=articles&action=view_list` → `admin\controls\Articles::view_list()`
|
||||||
|
|
||||||
|
Routing in `admin/index.php`: reads `$_GET['a']` → dynamically loads control class → calls action method.
|
||||||
|
|
||||||
|
## Frontend Routing
|
||||||
|
|
||||||
|
`index.php` → `front\controls\Site::route()` — checks `\S::get('search')`, `\S::get('tag')`, `\S::get('article')`, then falls through to page rendering by `page_type`.
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
|
||||||
|
| Cache Type | Location | Engine |
|
||||||
|
|-----------|----------|--------|
|
||||||
|
| Page cache | `cache/` | Full HTML output |
|
||||||
|
| Object cache | `temp/md5[0]/md5[1]/` | gzdeflate + serialize, TTL |
|
||||||
|
| WebP images | `cache/` | Filesystem |
|
||||||
|
| Language strings | `$_SESSION` | PHP session |
|
||||||
|
|
||||||
|
## Plugin System
|
||||||
|
|
||||||
|
3 hook points in frontend lifecycle (files in `plugins/` directory, included if they exist):
|
||||||
|
1. `special-actions.php` — after language init, before routing
|
||||||
|
2. `special-actions-middle.php` — before cache check
|
||||||
|
3. `special-actions-end.php` — before final output
|
||||||
149
.paul/codebase/concerns.md
Normal file
149
.paul/codebase/concerns.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Technical Debt & Concerns
|
||||||
|
|
||||||
|
> Generated: 2026-04-26 | Prioritized by severity
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
### C1 — Unserialize on User-Controlled Cookies
|
||||||
|
**File**: `admin/ajax/pages.php` lines 36, 49
|
||||||
|
**Code**: `$array = unserialize($_COOKIE['cookie_menus']);`
|
||||||
|
**Risk**: Object injection / RCE — classic PHP vulnerability.
|
||||||
|
**Fix**: Replace with `json_decode($_COOKIE['cookie_menus'] ?? '{}', true)`.
|
||||||
|
|
||||||
|
### C2 — Path Traversal in Update File Deletion
|
||||||
|
**File**: `autoload/admin/factory/class.Update.php` lines 76-80, 119-128
|
||||||
|
**Code**: `unlink('../' . $filePath)` — `$filePath` from JSON manifest, not validated.
|
||||||
|
**Risk**: Attacker-controlled manifest could delete arbitrary files.
|
||||||
|
**Fix**:
|
||||||
|
```php
|
||||||
|
$full = realpath('../' . $filePath);
|
||||||
|
$base = realpath('../');
|
||||||
|
if (strpos($full, $base) !== 0) throw new \Exception('Path traversal');
|
||||||
|
unlink($full);
|
||||||
|
```
|
||||||
|
|
||||||
|
### C3 — God Class: Helpers.php (1220 lines, 75+ static methods)
|
||||||
|
**File**: `autoload/Shared/Helpers/Helpers.php`
|
||||||
|
**Risk**: Unmaintainable, untestable, global state dependency (`global $mdb, $settings, $lang`).
|
||||||
|
**Domains mixed**: image processing, HTML DOM, caching, SEO, authentication, dates, session.
|
||||||
|
**Fix**: Extract into focused service classes (`ImageService`, `SeoHelper`, `DateHelper`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
### H1 — Direct Superglobal Access Without Validation
|
||||||
|
**File**: `autoload/Shared/Helpers/Helpers.php` lines 25-26
|
||||||
|
**Code**: `$crop_w = $_GET['c_w'];` — no isset, no type check.
|
||||||
|
**Also**: `admin/ajax/pages.php` lines 36, 49 — `\S::get()` passed directly to queries.
|
||||||
|
**Fix**: Centralized request wrapper with typed getters.
|
||||||
|
|
||||||
|
### H2 — SQL String Concatenation (String Values)
|
||||||
|
**File**: `autoload/Domain/Articles/ArticlesRepository.php` lines 53, 68, 87 and others.
|
||||||
|
**Code**: `"... WHERE article_id = " . (int)$id` — integer cast OK, but pattern is dangerous for string params.
|
||||||
|
**Fix**: Use Medoo parameterized methods exclusively. Audit and replace all raw `query()` calls.
|
||||||
|
|
||||||
|
### H3 — No Input Validation / Sanitization Layer
|
||||||
|
**All entry points** — no `Validator` or `Sanitizer` class. Values flow from `$_GET`/`$_POST` → repository without validation.
|
||||||
|
**Fix**: Add validation at control layer before delegation to factory/repository.
|
||||||
|
|
||||||
|
### H4 — Password Hash in Cookie
|
||||||
|
**File**: `admin/index.php` lines 59-61
|
||||||
|
**Code**: `$obj = json_decode($_COOKIE[$cookie_name]); $password = $obj->{'hash'};`
|
||||||
|
**Risk**: Cookie exposure leaks credential hash, no HMAC signing.
|
||||||
|
**Fix**: Use signed JWT or HMAC-signed remember-me token, never store hashes in cookies.
|
||||||
|
|
||||||
|
### H5 — Update Download Without Signature Verification
|
||||||
|
**File**: `autoload/admin/factory/class.Update.php` lines 12, 25, 28
|
||||||
|
**Code**: `file_get_contents('https://www.cmspro.project-dc.pl/updates/...')`
|
||||||
|
**Risk**: MITM, supply chain — ZIP extracted without verifying integrity beyond SHA256 (if present).
|
||||||
|
**Fix**: Verify SHA256 checksum server-side before extraction; use curl with `CURLOPT_SSL_VERIFYPEER`.
|
||||||
|
|
||||||
|
### H6 — Deprecated `mime_content_type()` Removed in PHP 8.1
|
||||||
|
**File**: `autoload/Shared/Helpers/Helpers.php` line 39
|
||||||
|
**Fix**:
|
||||||
|
```php
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$type = finfo_file($finfo, $file);
|
||||||
|
finfo_close($finfo);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
### M1 — Global Variables as Dependency Injection
|
||||||
|
**Files**: Factory classes (`global $mdb`, `global $user`), Helpers (`global $settings, $lang`).
|
||||||
|
**Risk**: Untestable, tightly coupled, order-dependent initialization.
|
||||||
|
**Fix**: Pass `$mdb` to factories/repositories directly; remove `global` from repository code.
|
||||||
|
|
||||||
|
### M2 — Repository Classes Contain Business Logic and Side Effects
|
||||||
|
**File**: `autoload/Domain/Articles/ArticlesRepository.php` line 45, 59
|
||||||
|
**Code**: `\S::delete_cache()` and `\S::seo()` called inside repository methods.
|
||||||
|
**Fix**: Repositories should only do DB operations; call side effects in factories/services.
|
||||||
|
|
||||||
|
### M3 — Mixed Procedural + OOP AJAX Handlers
|
||||||
|
**Files**: `admin/ajax/pages.php`, `admin/ajax/articles.php`, `admin/ajax/users.php`
|
||||||
|
**Pattern**: 50-90 line `if ($a == '...')` chains, no routing abstraction.
|
||||||
|
**Fix**: Create `AjaxRouter` + controller base class.
|
||||||
|
|
||||||
|
### M4 — No Request/Response Abstraction
|
||||||
|
**All entry points** — `$_GET`/`$_POST` accessed directly everywhere.
|
||||||
|
**Fix**: `Request` class (typed getters) + `JsonResponse` class.
|
||||||
|
|
||||||
|
### M5 — Error Suppression with `@` Operator
|
||||||
|
**Files**: `admin/index.php` lines 2, 14; Helpers.php lines 40, 98, 111, 1188-1200
|
||||||
|
**Code**: `@file_get_contents(...)`, `@unlink(...)`.
|
||||||
|
**Fix**: Use `if (file_exists())` guards and proper try/catch.
|
||||||
|
|
||||||
|
### M6 — Uninitialized Variables
|
||||||
|
**File**: `autoload/Domain/Articles/ArticlesRepository.php` line 72
|
||||||
|
**Code**: `if ($out == '')` — `$out` never declared.
|
||||||
|
**Fix**: `$out = '';` before the loop.
|
||||||
|
|
||||||
|
### M7 — No Interface Contracts for Repositories
|
||||||
|
All 10 repositories share identical method signatures but no shared interface.
|
||||||
|
**Fix**: Define `RepositoryInterface` with `find()`, `all()`, `save()`, `delete()`.
|
||||||
|
|
||||||
|
### M8 — Hardcoded Values
|
||||||
|
- Update base URL: `'https://www.cmspro.project-dc.pl/updates/'` in 3 files
|
||||||
|
- File permissions: `chmod(..., 0755)` in 25 places
|
||||||
|
- Cookie expiry: `time() + 3600 * 24 * 365` as magic number
|
||||||
|
**Fix**: Extract to constants in a config class.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
### L1 — Backup Files in Repository
|
||||||
|
`libraries/medoo/medoo.bck.php` (973 lines), `libraries/grid/gdb.min.bck.php` (957 lines).
|
||||||
|
**Fix**: Delete; use Git for history.
|
||||||
|
|
||||||
|
### L2 — `test.php` in Project Root (700 lines)
|
||||||
|
Production benchmark/test script accessible via HTTP. Contains DB credentials in lines 15-17.
|
||||||
|
**Fix**: Remove or move to `tests/` with `.htaccess` protection.
|
||||||
|
|
||||||
|
### L3 — Legacy `class.S.php` Wrapper
|
||||||
|
200+ calls to `\S::*` throughout codebase — double indirection through `__callStatic`.
|
||||||
|
**Fix**: Gradual rename campaign to `\Shared\Helpers\Helpers::*`.
|
||||||
|
|
||||||
|
### L4 — Legacy SQL Update Fallback Format
|
||||||
|
`class.Update.php` lines 97-132 — parses old `_sql.txt` format alongside new JSON manifest.
|
||||||
|
**Fix**: Deprecate and remove once all deployments are on manifest format.
|
||||||
|
|
||||||
|
### L5 — Update Process Without Rollback
|
||||||
|
SQL runs before file extraction. If extraction fails, DB is inconsistent. No transaction wrapping.
|
||||||
|
**Fix**: Wrap SQL in transaction; extract files first, then run SQL; add rollback on failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Needing Immediate Attention
|
||||||
|
|
||||||
|
| File | Lines | Issue |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `autoload/Shared/Helpers/Helpers.php` | 1220 | God class (C3) |
|
||||||
|
| `autoload/admin/factory/class.Update.php` | 157 | Path traversal (C2), supply chain (H5) |
|
||||||
|
| `admin/ajax/pages.php` | ~90 | Unserialize (C1), missing validation (H1) |
|
||||||
|
| `admin/index.php` | — | Password hash in cookie (H4) |
|
||||||
|
| `autoload/Domain/Articles/ArticlesRepository.php` | 648 | Side effects in repo (M2), raw SQL (H2) |
|
||||||
|
| `test.php` | 700 | Remove from root (L2) |
|
||||||
161
.paul/codebase/conventions.md
Normal file
161
.paul/codebase/conventions.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
> Generated: 2026-04-26
|
||||||
|
|
||||||
|
## File Naming
|
||||||
|
|
||||||
|
| Layer | Convention | Example |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Legacy (admin/front) | `class.{ClassName}.php` | `class.Articles.php` |
|
||||||
|
| Domain repositories | `{ClassName}.php` (PSR-4) | `ArticlesRepository.php` |
|
||||||
|
| Shared services | `{ClassName}.php` (PSR-4) | `CacheHandler.php` |
|
||||||
|
| Templates | `{feature-name}.php` | `articles/list.php` |
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
| Element | Legacy code | New Domain code |
|
||||||
|
|---------|------------|-----------------|
|
||||||
|
| Methods | `snake_case` | `camelCase` |
|
||||||
|
| Classes | `PascalCase` | `PascalCase` |
|
||||||
|
| Properties | `$camelCase` | `$camelCase` |
|
||||||
|
| Constants | `UPPER_CASE` | `UPPER_CASE` |
|
||||||
|
| Namespaces | lowercase (`admin\`, `front\`) | PascalCase (`Domain\`, `Shared\`) |
|
||||||
|
|
||||||
|
## Class Patterns
|
||||||
|
|
||||||
|
### Controls (request handlers) — static methods only
|
||||||
|
```php
|
||||||
|
namespace admin\controls;
|
||||||
|
class Articles {
|
||||||
|
public static function article_delete() {
|
||||||
|
global $user;
|
||||||
|
if (!admin\factory\Users::check_privileges('articles', $user['id']))
|
||||||
|
return \S::alert('Brak uprawnień');
|
||||||
|
admin\factory\Articles::article_delete(\S::get('article_id'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Factories — @deprecated wrappers, static methods, delegate to repo
|
||||||
|
```php
|
||||||
|
namespace admin\factory;
|
||||||
|
/** @deprecated Wrapper — używaj \Domain\Articles\ArticlesRepository przez DI */
|
||||||
|
class Articles {
|
||||||
|
private static function repo(): \Domain\Articles\ArticlesRepository {
|
||||||
|
global $mdb;
|
||||||
|
return new \Domain\Articles\ArticlesRepository($mdb);
|
||||||
|
}
|
||||||
|
public static function article_delete($id): bool {
|
||||||
|
return self::repo()->deleteArticle((int)$id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Repositories — constructor DI, camelCase, typed returns
|
||||||
|
```php
|
||||||
|
namespace Domain\Articles;
|
||||||
|
class ArticlesRepository {
|
||||||
|
private $db;
|
||||||
|
public function __construct($db) { $this->db = $db; }
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Odczyt (Read)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
public function find(int $id): ?array {
|
||||||
|
return $this->db->get('pp_articles', '*', ['id' => $id]) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Zapis / usuwanie (Write / Delete)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
public function deleteArticle(int $id): bool {
|
||||||
|
$this->db->delete('pp_articles', ['id' => $id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### View classes — static rendering
|
||||||
|
```php
|
||||||
|
namespace admin\view;
|
||||||
|
class Articles {
|
||||||
|
public static function list($articles) {
|
||||||
|
$tpl = new \Tpl;
|
||||||
|
$tpl->articles = $articles;
|
||||||
|
return $tpl->render('articles/list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## PHPDoc Style
|
||||||
|
|
||||||
|
Polish-language descriptions are standard in this project:
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Prosta lista autorów
|
||||||
|
* @return array|bool
|
||||||
|
*/
|
||||||
|
public function authorsList() { ... }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapis autora (insert lub update)
|
||||||
|
* @param int $authorId
|
||||||
|
* @param string $author
|
||||||
|
* @return object|bool
|
||||||
|
*/
|
||||||
|
public function authorSave(int $authorId, string $author) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Section separators in larger classes:
|
||||||
|
```php
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Odczyt (Read operations)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
```
|
||||||
|
|
||||||
|
## Return Patterns
|
||||||
|
|
||||||
|
| Pattern | Usage |
|
||||||
|
|---------|-------|
|
||||||
|
| `?array` | Single record lookup (null = not found) |
|
||||||
|
| `array` (possibly `[]`) | List queries — `?: []` fallback |
|
||||||
|
| `bool` | Write/delete operations |
|
||||||
|
| `int` | Codes: `1 = OK`, `0 = bad credentials`, `-1 = blocked` |
|
||||||
|
| `void` | Side-effect-only writes |
|
||||||
|
| `['status' => 'ok'/'error', 'msg' => '...']` | AJAX JSON responses |
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Repositories return `null`/`false`/`[]` for "not found", don't throw
|
||||||
|
- `ImageManipulator` uses typed exceptions (`\InvalidArgumentException`, `\RuntimeException`)
|
||||||
|
- AJAX endpoints: `json_encode(['status' => 'ok/error', 'msg' => '...'])`
|
||||||
|
- Error suppression with `@` is used in legacy code (avoid in new code)
|
||||||
|
|
||||||
|
## Database Access via Medoo
|
||||||
|
|
||||||
|
Always use parameterized Medoo methods — never string concatenation with string values:
|
||||||
|
```php
|
||||||
|
// Good
|
||||||
|
$this->db->get('pp_articles', '*', ['id' => $id]);
|
||||||
|
$this->db->select('pp_articles', '*', ['ORDER' => ['created' => 'DESC']]);
|
||||||
|
$this->db->update('pp_articles', ['status' => 1], ['id' => $id]);
|
||||||
|
$this->db->insert('pp_articles', ['title' => $title, 'slug' => $slug]);
|
||||||
|
|
||||||
|
// Acceptable (integer cast only)
|
||||||
|
$this->db->query("SELECT ... WHERE id = " . (int)$id)->fetchAll();
|
||||||
|
|
||||||
|
// Never
|
||||||
|
$this->db->query("SELECT ... WHERE slug = '" . $slug . "'"); // SQL injection risk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global Helper Facade (`\S::`)
|
||||||
|
|
||||||
|
Legacy code uses `\S::method()` — new code should use `\Shared\Helpers\Helpers::method()` directly or inject the dependency. Migrate `\S::` calls opportunistically but don't block on it.
|
||||||
|
|
||||||
|
## Template Rendering
|
||||||
|
|
||||||
|
```php
|
||||||
|
$tpl = new \Tpl; // or: new \Shared\Tpl\Tpl
|
||||||
|
$tpl->variable = $value; // assign template variables
|
||||||
|
return $tpl->render('module/template-name'); // checks templates_user/ first, then templates/
|
||||||
|
```
|
||||||
63
.paul/codebase/integrations.md
Normal file
63
.paul/codebase/integrations.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# External Integrations
|
||||||
|
|
||||||
|
> Generated: 2026-04-26
|
||||||
|
|
||||||
|
## Email — PHPMailer + SMTP
|
||||||
|
|
||||||
|
- **Library**: PHPMailer (`libraries/phpmailer/class.phpmailer.php`)
|
||||||
|
- **Service class**: `autoload/Shared/Email/Email.php`
|
||||||
|
- **Configuration**: stored in `pp_settings` table
|
||||||
|
- Keys: `email_host`, `email_port`, `email_login`, `email_password`, `contact_email`, `firm_name`
|
||||||
|
- **Features**: SSL/TLS, self-signed cert support, HTML email, attachments, relative URL conversion
|
||||||
|
- **Used by**: Newsletter cron, contact forms, 2FA code sending
|
||||||
|
|
||||||
|
## Geolocation — geoPlugin
|
||||||
|
|
||||||
|
- **Provider**: geoPlugin (http://www.geoplugin.net/)
|
||||||
|
- **Class**: `autoload/class.geoplugin.php`
|
||||||
|
- **Features**: IP-to-country, currency detection, exchange rates
|
||||||
|
- **Integration**: loaded in frontend via autoloader, used for localization hints
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
- **Type**: configurable (any script tag)
|
||||||
|
- **Storage**: `pp_settings.statistic_code` field
|
||||||
|
- **Injection**: `index.php` lines ~121-122 — injected into HTML `<head>` via string replacement
|
||||||
|
- **Default**: empty (disabled until configured in admin Settings)
|
||||||
|
|
||||||
|
## Updates — cmspro.project-dc.pl
|
||||||
|
|
||||||
|
- **Factory**: `autoload/admin/factory/class.Update.php`
|
||||||
|
- **Base URL**: `https://www.cmspro.project-dc.pl/updates/` (hardcoded)
|
||||||
|
- **Endpoints used**:
|
||||||
|
- `versions.php?key={update_key}` — fetch available versions list
|
||||||
|
- `{dir}/ver_{version}.zip` — download update ZIP
|
||||||
|
- `{dir}/ver_{version}_sql.txt` — legacy SQL migration fallback
|
||||||
|
- **Auth**: `update_key` from `pp_settings`, validated on server
|
||||||
|
- **License**: `pp_update_licenses` table — `valid_to_date`, `valid_to_version`, `beta` flag
|
||||||
|
- **Channels**: stable / beta
|
||||||
|
|
||||||
|
**Security note**: `file_get_contents()` over HTTPS, no signature verification, path not sanitized.
|
||||||
|
See `concerns.md` for details.
|
||||||
|
|
||||||
|
## File Manager
|
||||||
|
|
||||||
|
- **Library**: FileManager 9.14.1 (`libraries/filemanager-9.14.1/`)
|
||||||
|
- **API endpoint**: `upload/filemanager/api/`
|
||||||
|
- **Features**: file upload, deletion, browsing via AJAX
|
||||||
|
- **MIME validation**: JPEG, PNG, GIF, WebP allowed
|
||||||
|
- **Organization**: files stored by article ID under `upload/`
|
||||||
|
|
||||||
|
## Mobile Detection
|
||||||
|
|
||||||
|
- **Library**: Mobile_Detect 2.8.16 (`autoload/class.Mobile_Detect.php`)
|
||||||
|
- **Usage**: UA-based device detection for mobile/tablet
|
||||||
|
- **Integration**: used in frontend factory to adapt output
|
||||||
|
|
||||||
|
## No Payment Integration
|
||||||
|
|
||||||
|
No PayPal, Stripe, or other payment processor code detected.
|
||||||
|
|
||||||
|
## No CDN
|
||||||
|
|
||||||
|
Images served locally. WebP conversion cached in `cache/` directory.
|
||||||
54
.paul/codebase/overview.md
Normal file
54
.paul/codebase/overview.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# cmsPRO — Project Overview
|
||||||
|
|
||||||
|
> Generated: 2026-04-26 | Milestone: v0.1 Refaktoryzacja
|
||||||
|
|
||||||
|
## What is cmsPRO?
|
||||||
|
|
||||||
|
cmsPRO is a Polish-language PHP CMS with a **hybrid transitional architecture**. The codebase is actively being refactored from a legacy procedural/OOP mixed approach toward a clean Domain-Driven Design structure with Repository pattern.
|
||||||
|
|
||||||
|
## Core Capabilities
|
||||||
|
|
||||||
|
| Module | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Articles | CRUD, multi-language, versioning, scheduling, galleries, tags, SEO |
|
||||||
|
| Pages | Static pages with layouts, caching, inline editing |
|
||||||
|
| Newsletter | Subscription, templates, cron-based batch sending |
|
||||||
|
| Layouts | HTML/CSS template system with Savant3 rendering |
|
||||||
|
| Users | Admin users, privileges matrix, 2FA support |
|
||||||
|
| Languages | Multi-language content, URL routing, session caching |
|
||||||
|
| Banners | Homepage banners with multi-language support |
|
||||||
|
| Scontainers | Reusable content blocks/widgets |
|
||||||
|
| Authors | Author management for articles |
|
||||||
|
| SEO | Meta tags, slugs, noindex, robots.txt, sitemap |
|
||||||
|
| File Manager | Upload, browse, thumbnail generation |
|
||||||
|
| Settings | DB-stored site config, WebP toggle, lazy loading |
|
||||||
|
| Updates | Versioned ZIP updates with license validation |
|
||||||
|
| Backups | DB backup/restore utilities |
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `index.php` | Frontend entry point and router |
|
||||||
|
| `admin/index.php` | Admin panel entry point |
|
||||||
|
| `ajax.php` | Frontend AJAX handler |
|
||||||
|
| `admin/ajax.php` | Admin AJAX handler (routes to `admin/ajax/*.php`) |
|
||||||
|
| `api.php` | API endpoint |
|
||||||
|
| `cron.php` | Scheduled tasks (newsletter batch sending) |
|
||||||
|
| `download.php` | File download handler |
|
||||||
|
|
||||||
|
## Current Refactoring Status
|
||||||
|
|
||||||
|
The project is in **Phase 5 of Milestone v0.1 Refaktoryzacja**.
|
||||||
|
|
||||||
|
Migration pattern:
|
||||||
|
- **Done**: Domain repositories created for all 10 main entities
|
||||||
|
- **Done**: Factory classes converted to deprecated wrappers delegating to repositories
|
||||||
|
- **In progress**: SeoAdditional, Cron, Releases domains
|
||||||
|
- **Pending**: Remove factory layer, inject repositories directly into controls
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
- Current app version: **1.695**
|
||||||
|
- Update channel: stable/beta via `updates/` ZIP packages
|
||||||
|
- License validation via `pp_update_licenses` table
|
||||||
80
.paul/codebase/stack.md
Normal file
80
.paul/codebase/stack.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
> Generated: 2026-04-26
|
||||||
|
|
||||||
|
## PHP Runtime
|
||||||
|
|
||||||
|
- **Required**: PHP 7.4+ (nikic/php-parser constraint), PHP 7.1+ / 8.0+ (deep-copy)
|
||||||
|
- **Composer**: `composer.json` at project root
|
||||||
|
- **Dev dependency**: `phpunit/phpunit: ^10.5`
|
||||||
|
- **No runtime Composer packages** — all libraries are vendored manually in `libraries/`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Engine | MySQL |
|
||||||
|
| Config | `config.php` (plain-text credentials) |
|
||||||
|
| Abstraction | Medoo 1.7.3 (`libraries/medoo/medoo.php`) |
|
||||||
|
| Table prefix | `pp_` |
|
||||||
|
| Remote host | `host117523.hostido.net.pl` (hostido.net.pl hosting) |
|
||||||
|
|
||||||
|
Key tables: `pp_articles`, `pp_articles_langs`, `pp_pages`, `pp_layouts`, `pp_users`, `pp_users_privileges`, `pp_newsletter`, `pp_newsletter_templates`, `pp_banners`, `pp_scontainers`, `pp_authors`, `pp_languages`, `pp_settings`, `pp_tags`, `pp_update_versions`, `pp_update_licenses`
|
||||||
|
|
||||||
|
## Frontend Libraries (all vendored in `libraries/`)
|
||||||
|
|
||||||
|
| Library | Version | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| jQuery | 2.1.3 | JavaScript DOM |
|
||||||
|
| Bootstrap | 4.1.3 | CSS/JS framework |
|
||||||
|
| Font Awesome | 4.7.0 | Icons |
|
||||||
|
| jQuery UI | — | UI widgets |
|
||||||
|
| CKEditor | — | WYSIWYG editor |
|
||||||
|
| Leaflet | — | Maps (in CKEditor plugin) |
|
||||||
|
| Plupload | 3.1.2 | File upload |
|
||||||
|
| jQuery Confirm | — | Confirmation dialogs |
|
||||||
|
| FancyBox | — | Lightbox/modal |
|
||||||
|
| CodeMirror | — | Code editor |
|
||||||
|
| Lozad.js | — | Lazy loading |
|
||||||
|
| MotionCAPTCHA | — | CAPTCHA |
|
||||||
|
| FileManager | 9.14.1 | File browse/upload UI |
|
||||||
|
|
||||||
|
**No build tools** — no webpack, vite, or gulp. Raw JS/CSS files.
|
||||||
|
|
||||||
|
Custom JS: `libraries/functions.js`, `libraries/functions-front.js`, `libraries/jquery/javascript.js`
|
||||||
|
|
||||||
|
## PHP Libraries (vendored)
|
||||||
|
|
||||||
|
| Library | Location | Purpose |
|
||||||
|
|---------|----------|---------|
|
||||||
|
| PHPMailer | `libraries/phpmailer/` | SMTP email (class.phpmailer.php, class.smtp.php) |
|
||||||
|
| Medoo | `libraries/medoo/medoo.php` | Database abstraction |
|
||||||
|
| MySQLDump | `libraries/MySQLDump.php` | SQL dump utility |
|
||||||
|
| Savant3 | `autoload/Savant3.php` | Template engine |
|
||||||
|
| Mobile_Detect | `autoload/class.Mobile_Detect.php` | 2.8.16, device detection |
|
||||||
|
| geoPlugin | `autoload/class.geoplugin.php` | IP geolocation |
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
- **Apache** with mod_rewrite, mod_deflate, mod_expires
|
||||||
|
- Config: `.htaccess` — HTTPS redirect, www enforcement, trailing slash, gzip, 1-year browser cache
|
||||||
|
- Optional admin IP whitelist: `admin/ip.conf`
|
||||||
|
- Session: PHP native sessions with IP validation and regeneration
|
||||||
|
- Cache: File-based in `cache/` and `temp/` directories
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
| Service | Purpose | Integration |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| SMTP (configurable) | Email delivery | PHPMailer, settings in `pp_settings` |
|
||||||
|
| geoPlugin (geoplugin.net) | IP geolocation | `class.geoplugin.php` |
|
||||||
|
| cmspro.project-dc.pl | Update downloads | `autoload/admin/factory/class.Update.php` line 12, 25 |
|
||||||
|
| Analytics (configurable) | Stats injection | `pp_settings.statistic_code` → injected in `<head>` |
|
||||||
|
|
||||||
|
## Autoloading
|
||||||
|
|
||||||
|
Hybrid custom autoloader at `autoload/autoloader.php`:
|
||||||
|
1. Tries `autoload/{namespace}/class.{ClassName}.php` (legacy)
|
||||||
|
2. Falls back to `autoload/{namespace}/{ClassName}.php` (PSR-4)
|
||||||
|
|
||||||
|
Composer PSR-4 mappings: `Domain\` → `autoload/Domain/`, `Shared\` → `autoload/Shared/`
|
||||||
124
.paul/codebase/testing.md
Normal file
124
.paul/codebase/testing.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
> Generated: 2026-04-26
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
- **PHPUnit 10.5+** (`phpunit/phpunit` in `composer.json` dev)
|
||||||
|
- Config: `phpunit.xml` at project root
|
||||||
|
- Bootstrap: `tests/bootstrap.php`
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── bootstrap.php Test bootstrap (PSR-4 autoload for Domain\)
|
||||||
|
├── stubs/
|
||||||
|
│ ├── CacheHandler.php In-memory stub (replaces file-based cache)
|
||||||
|
│ └── S.php Helper facade stub
|
||||||
|
└── Unit/
|
||||||
|
└── Domain/
|
||||||
|
├── Languages/LanguagesRepositoryTest.php
|
||||||
|
├── Settings/SettingsRepositoryTest.php
|
||||||
|
└── User/UserRepositoryTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bootstrap Setup
|
||||||
|
|
||||||
|
`tests/bootstrap.php`:
|
||||||
|
- Loads Medoo ORM (`libraries/medoo/medoo.php`)
|
||||||
|
- Loads stubs **before** autoloader (to override `Shared\Cache\CacheHandler`)
|
||||||
|
- Registers PSR-4 autoloader for `Domain\` namespace only
|
||||||
|
|
||||||
|
**Critical**: Stubs must be loaded before autoloader. CacheHandler stub provides `reset()` method for test isolation.
|
||||||
|
|
||||||
|
## Test Pattern
|
||||||
|
|
||||||
|
All tests follow **AAA (Arrange-Act-Assert)** with Medoo mocked:
|
||||||
|
|
||||||
|
```php
|
||||||
|
namespace Tests\Unit\Domain\Languages;
|
||||||
|
|
||||||
|
use Domain\Languages\LanguagesRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class LanguagesRepositoryTest extends TestCase {
|
||||||
|
private function mockDb(): object {
|
||||||
|
return $this->createMock(\medoo::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
\Shared\Cache\CacheHandler::reset(); // clear in-memory cache
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLanguagesListReturnsArray(): void {
|
||||||
|
$db = $this->mockDb();
|
||||||
|
$db->method('select')->willReturn([['id' => 'pl', 'name' => 'Polski']]);
|
||||||
|
|
||||||
|
$repo = new LanguagesRepository($db);
|
||||||
|
$result = $repo->languagesList();
|
||||||
|
|
||||||
|
$this->assertSame([['id' => 'pl', 'name' => 'Polski']], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLanguagesListReturnsEmptyWhenNull(): void {
|
||||||
|
$db = $this->mockDb();
|
||||||
|
$db->method('select')->willReturn(null);
|
||||||
|
$this->assertSame([], (new LanguagesRepository($db))->languagesList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testActiveLanguagesQueriesDbAndCaches(): void {
|
||||||
|
$expected = [['id' => 'pl', 'name' => 'Polski', 'domain' => null]];
|
||||||
|
$db = $this->mockDb();
|
||||||
|
$db->expects($this->once())->method('select')->willReturn($expected);
|
||||||
|
|
||||||
|
$repo = new LanguagesRepository($db);
|
||||||
|
$this->assertSame($expected, $repo->activeLanguages());
|
||||||
|
$this->assertSame($expected, $repo->activeLanguages()); // 2nd call hits cache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stubs
|
||||||
|
|
||||||
|
### `tests/stubs/CacheHandler.php`
|
||||||
|
In-memory replacement for `Shared\Cache\CacheHandler`:
|
||||||
|
- `static::$store` — array key-value store
|
||||||
|
- `reset()` — clear all stored values (call in `setUp()`)
|
||||||
|
- `fetch($key)` — return stored value or `false`
|
||||||
|
- `store($key, $value, $ttl)` — store value (TTL ignored)
|
||||||
|
- `delete($key)` — remove value
|
||||||
|
|
||||||
|
### `tests/stubs/S.php`
|
||||||
|
Stub for the `\S` global helper facade — prevents tests from hitting real filesystem/session code.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
Currently tested: **Domain layer only**
|
||||||
|
- `Domain\Languages\LanguagesRepository` ✓
|
||||||
|
- `Domain\Settings\SettingsRepository` ✓
|
||||||
|
- `Domain\User\UserRepository` ✓
|
||||||
|
- All other Domain repositories: **no tests yet**
|
||||||
|
|
||||||
|
Not tested:
|
||||||
|
- `admin\controls\*` — static controllers
|
||||||
|
- `admin\factory\*` — deprecated wrappers
|
||||||
|
- `front\*` — frontend layer
|
||||||
|
- `Shared\*` — utilities
|
||||||
|
- AJAX handlers
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer test
|
||||||
|
# or
|
||||||
|
./vendor/bin/phpunit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding Tests for New Repositories
|
||||||
|
|
||||||
|
When adding a new `Domain\{Entity}\{Entity}Repository`:
|
||||||
|
1. Create `tests/Unit/Domain/{Entity}/{Entity}RepositoryTest.php`
|
||||||
|
2. Call `\Shared\Cache\CacheHandler::reset()` in `setUp()` if the repo uses caching
|
||||||
|
3. Mock `\medoo` via `$this->createMock(\medoo::class)`
|
||||||
|
4. Test: null-to-empty-array coercion, cache hit (expects `once()`), write returns expected type
|
||||||
Reference in New Issue
Block a user