-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpshookinspector.php
More file actions
273 lines (234 loc) · 10 KB
/
Copy pathpshookinspector.php
File metadata and controls
273 lines (234 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
<?php
if (!defined('_PS_VERSION_')) {
exit;
}
require_once _PS_MODULE_DIR_ . 'pshookinspector/classes/PsHookInspectorAuthToken.php';
require_once _PS_MODULE_DIR_ . 'pshookinspector/classes/PsHookInspectorActionHookList.php';
require_once _PS_MODULE_DIR_ . 'pshookinspector/classes/PsHookInspectorRegistry.php';
require_once _PS_MODULE_DIR_ . 'pshookinspector/classes/PsHookInspectorWrapper.php';
class PsHookInspector extends Module
{
private const CONFIG_ENABLED = 'PSHOOKINSPECTOR_ENABLED';
public function __construct()
{
$this->name = 'pshookinspector';
$this->tab = 'administration';
$this->version = '0.1.0';
$this->author = 'Marcin Bręczewski';
$this->need_instance = 0;
$this->bootstrap = true;
// TODO: pin 'max' once the target PS9 minor is known.
$this->ps_versions_compliancy = ['min' => '8.0', 'max' => '9.99.99'];
parent::__construct();
$this->displayName = $this->l('Hook Inspector');
$this->description = $this->l(
'Attributes rendered front-office markup to the module and hook that produced it, '
. 'and reports hooked modules that rendered nothing.'
);
}
/** All three methods our override touches — see override/classes/Hook.php. */
private const OVERRIDDEN_METHODS = ['coreCallHook', 'coreRenderWidget', 'exec'];
public function install()
{
$conflict = $this->conflictingOverrideMethod();
if ($conflict !== null) {
$this->_errors[] = sprintf(
$this->l(
'Another module already overrides classes/Hook.php::%s(). '
. 'Hook Inspector needs that exact method and cannot install until the conflict is resolved.'
),
$conflict
);
return false;
}
return parent::install()
&& Configuration::updateValue(self::CONFIG_ENABLED, false)
&& $this->registerHook('actionDispatcher')
&& $this->registerHook('actionFrontControllerSetMedia')
&& $this->registerHook('displayFooter');
}
public function uninstall()
{
return Configuration::deleteByName(self::CONFIG_ENABLED)
&& Configuration::deleteByName(PsHookInspectorAuthToken::CONFIG_SECRET)
&& parent::uninstall();
}
/**
* actionDispatcher fires on every request, before any output — early enough
* to set a cookie. It only ever does anything when a token was just issued
* from renderOpenFrontLink() and is present in the URL; every other request
* hits the early return.
*/
public function hookActionDispatcher(array $params): void
{
$token = Tools::getValue('psi_token');
if (!is_string($token) || $token === '' || PsHookInspectorAuthToken::verify($token) === null) {
return;
}
setcookie(
PsHookInspectorAuthToken::cookieName(),
$token,
[
'expires' => time() + PsHookInspectorAuthToken::ttlSeconds(),
'path' => '/',
'secure' => (bool) Tools::usingSecureMode(),
'httponly' => true,
'samesite' => 'Lax',
]
);
}
/**
* No point loading the overlay JS/CSS for a browser that will never see
* markers — reuses the exact same gate as the markers themselves.
*/
public function hookActionFrontControllerSetMedia(array $params): void
{
if (!PsHookInspectorWrapper::isEnabled()) {
return;
}
$this->context->controller->registerStylesheet(
'pshookinspector-inspector',
'modules/' . $this->name . '/views/css/inspector.css'
);
$this->context->controller->registerJavascript(
'pshookinspector-inspector',
'modules/' . $this->name . '/views/js/inspector.js'
);
}
/**
* Dumps the in-request registry diff as JSON for inspector.js to read —
* simpler than a separate AJAX endpoint, since all the data this needs
* only exists for the lifetime of this exact request anyway. Fires late
* (footer is near the end of most themes' templates) so the collected data
* is as complete as it'll get, though anything hooked after displayFooter
* would be missed — a known gap.
*/
public function hookDisplayFooter(array $params): string
{
if (!PsHookInspectorWrapper::isEnabled()) {
return '';
}
$json = json_encode(
PsHookInspectorRegistry::diff(),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
if ($json === false) {
return '';
}
return '<script type="application/json" id="psi-registry-data">' . $json . '</script>';
}
/**
* Back-office toggle, off by default. The token half of the gate lives in
* PsHookInspectorWrapper::isEnabled() / PsHookInspectorAuthToken, not here —
* this page only controls the config flag and issues bootstrap links.
*/
public function getContent()
{
$output = '';
if (Tools::isSubmit('submitPsHookInspector')) {
Configuration::updateValue(self::CONFIG_ENABLED, (bool) Tools::getValue(self::CONFIG_ENABLED));
$this->clearRenderCaches();
$output .= $this->displayConfirmation($this->l('Settings updated.'));
}
if (Configuration::get(self::CONFIG_ENABLED)) {
$output .= $this->renderOpenFrontLink();
}
return $output . $this->renderForm();
}
/**
* The token is exposed in this URL and briefly in browser history / access logs —
* an accepted trade-off given it is short-lived (see TTL_SECONDS), HMAC-signed,
* and only ever grants inspector visibility, nothing else. Not built: stripping
* the token from the address bar after the cookie is set (would need a redirect
* from inside actionDispatcher, before the target controller loads — deferred).
*/
private function renderOpenFrontLink(): string
{
if (!Validate::isLoadedObject($this->context->employee)) {
return '';
}
$token = PsHookInspectorAuthToken::issue((int) $this->context->employee->id);
$url = $this->context->link->getBaseLink() . '?psi_token=' . urlencode($token);
return '<p>'
. '<a href="' . htmlspecialchars($url) . '" target="_blank" class="btn btn-primary">'
. htmlspecialchars($this->l('Open front office in inspection mode'))
. '</a> '
. htmlspecialchars(sprintf(
$this->l('Link valid for %d minutes.'),
(int) (PsHookInspectorAuthToken::ttlSeconds() / 60)
))
. '</p>';
}
private function renderForm(): string
{
$fieldsForm[0]['form'] = [
'legend' => [
'title' => $this->l('Hook Inspector'),
],
'input' => [
[
'type' => 'switch',
'label' => $this->l('Inspection mode'),
'name' => self::CONFIG_ENABLED,
'desc' => $this->l(
'Emits attribution markers in front-office HTML, but only for a browser holding a valid '
. 'inspection link issued below to a logged-in employee — never for anyone else. Leave off '
. 'outside active debugging. If Varnish, a CDN, or another cache module sits in front of '
. 'this shop, purge it manually after toggling — this only clears the Smarty cache.'
),
'values' => [
['id' => 'active_on', 'value' => 1, 'label' => $this->l('Enabled')],
['id' => 'active_off', 'value' => 0, 'label' => $this->l('Disabled')],
],
],
],
'submit' => [
'title' => $this->l('Save'),
],
];
$helper = new HelperForm();
$helper->module = $this;
$helper->name_controller = $this->name;
$helper->token = Tools::getAdminTokenLite('AdminModules');
$helper->currentIndex = AdminController::$currentIndex . '&configure=' . $this->name;
$helper->default_form_language = $this->context->language->id;
$helper->submit_action = 'submitPsHookInspector';
$helper->fields_value[self::CONFIG_ENABLED] = Configuration::get(self::CONFIG_ENABLED);
return $helper->generateForm($fieldsForm);
}
/**
* Cache invalidation on toggle is mandatory, not an optimisation — without it,
* disabling inspection mode can leave markers sitting in cached HTML. Only the
* Smarty cache is ours to clear; external cache layers need a manual purge (see README).
*/
private function clearRenderCaches(): void
{
$this->context->smarty->clearAllCache();
$this->context->smarty->clearCompiledTemplate();
}
/**
* Fails loudly and specifically instead of the generic core error when another
* module already holds one of the same override methods.
*
* PrestaShop merges per-method overrides of the same class automatically, so the
* presence of an existing override/classes/Hook.php is not itself a conflict —
* only a pre-existing override of one of OVERRIDDEN_METHODS specifically is.
*/
private function conflictingOverrideMethod(): ?string
{
$overridePath = _PS_ROOT_DIR_ . '/override/classes/Hook.php';
if (!file_exists($overridePath)) {
return null;
}
$contents = file_get_contents($overridePath);
if ($contents === false || strpos($contents, 'PsHookInspectorWrapper') !== false) {
return null;
}
foreach (self::OVERRIDDEN_METHODS as $method) {
if (preg_match('/function\s+' . $method . '\s*\(/i', $contents)) {
return $method;
}
}
return null;
}
}