Repository navigation
Expand file tree
/
Copy pathcontent.js
More file actions
403 lines (372 loc) · 16.5 KB
/
Copy pathcontent.js
File metadata and controls
403 lines (372 loc) · 16.5 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
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
/**
* WordPress Browser Extension — content script (document_idle)
*
* Responsibilities:
* 1. Run detection against the loaded DOM and report to background.
* 2. Respond to popup requests for live (fresh) detection.
* 3. Apply the admin bar visibility preference for this origin,
* and toggle in response to popup messages.
*/
(function () {
'use strict';
if (!document.body || !document.documentElement) return;
// Abort timeout for same-origin fetches issued from the content script. The
// HEAD host probe is awaited by the background worker (which holds the cache
// snapshot and message channel open meanwhile), so a hung server must not
// stall it indefinitely. AbortSignal.timeout: Chrome 103+ / Safari 16+.
const FETCH_TIMEOUT_MS = 10000;
// -- Detection + reporting -----------------------------------------------
const detection = globalThis.WPDetect.detectWordPress(document, { origin: location.origin, pathname: location.pathname });
if (!detection.context.isLoggedIn) {
detection.context.isLoggedIn =
globalThis.WPDetect.detectLoggedInFromCookies(document.cookie);
}
// Host detection: origin check first (catches local dev), then DOM
// scan (free). Both are sync — include the result so the background
// can skip the HEAD request when a signal is present.
const hostFromDOM = detection.isWordPress
? (globalThis.WPHost.detectHostFromOrigin(location.origin) ||
globalThis.WPHost.detectHostFromDOM(document))
: null;
try {
chrome.runtime.sendMessage({
type: 'WP_DETECTION',
url: location.href,
origin: location.origin,
pathname: location.pathname,
detection,
hostFromDOM,
});
} catch (_) { /* extension context invalidated */ }
// -- Admin bar visibility ------------------------------------------------
// Adopt the style lib/early.js injected at document_start — but only a
// node this extension actually created: a <style> carrying the
// data-wpd-owned marker. This script matches every http(s) page, and
// applyShow() removes whatever it adopted, so a page-owned element that
// merely shares the ID must never be picked up. The marker is not a
// security boundary (a page faking it only opts its own node into
// removal); it prevents accidental damage to page-owned DOM.
let hideStyle = document.getElementById('wp-detective-adminbar-hide');
if (
hideStyle &&
!(hideStyle.tagName === 'STYLE' && hideStyle.hasAttribute('data-wpd-owned'))
) {
hideStyle = null;
}
let removedClasses = [];
// The admin-bar and block-inspector paths both read wp_preferences_v1; share
// one storage round trip per page load instead of two-to-three.
let prefsRootPromise = null;
function loadPrefsRoot() {
if (!prefsRootPromise) {
prefsRootPromise = chrome.storage.local
.get('wp_preferences_v1')
.then((data) => data.wp_preferences_v1 || {})
.catch(() => ({}));
}
return prefsRootPromise;
}
async function loadAdminBarPref() {
const prefsRoot = await loadPrefsRoot();
const prefs = prefsRoot[location.origin];
const globalPrefs = prefsRoot._global || {};
// Per-origin choice wins; otherwise consult the global "hide by
// default" option set on the options page; otherwise default to shown.
if (prefs && typeof prefs.adminBarHidden === 'boolean') {
return prefs.adminBarHidden === true;
}
return globalPrefs.adminBarHidden === true;
}
function applyHide() {
if (!hideStyle) {
hideStyle = document.createElement('style');
hideStyle.id = 'wp-detective-adminbar-hide';
// Ownership marker — see the adoption check above applyHide.
hideStyle.setAttribute('data-wpd-owned', '');
// Shared rule set defined once in early.js (same content-script world).
hideStyle.textContent = globalThis.WPDAdminBarHideCSS || '';
document.documentElement.appendChild(hideStyle);
console.info(chrome.i18n.getMessage('admin_bar_hidden_notice')); // "[WordPress Browser Extension] Admin bar hidden on this site. Toggle "Show Admin Bar" in the extension popup or the options page."
}
// Remove `body.admin-bar` so themes that key layout off it (e.g.
// `body.admin-bar .header { padding-top: 32px }`) collapse cleanly
// alongside the html-margin neutralization above. We deliberately
// do NOT remove `body.logged-in` — themes use that class for
// member-only UI (account links, "Welcome, {user}", hide-login-form,
// etc.), and hiding the admin bar shouldn't simulate a logout.
if (document.body && document.body.classList.contains('admin-bar')) {
document.body.classList.remove('admin-bar');
if (!removedClasses.includes('admin-bar')) removedClasses.push('admin-bar');
}
}
function applyShow() {
if (hideStyle) {
hideStyle.remove();
hideStyle = null;
}
if (document.body) {
removedClasses.forEach((cls) => document.body.classList.add(cls));
removedClasses = [];
}
// Note: the admin bar's own JS initializes on page load. Hover menus,
// notifications, etc. may not function fully until the page is
// reloaded. Visually it's correct; interactively it may be partial.
}
// Only manage admin bar visibility on the front end — never inside
// wp-admin, where the toolbar is integral to the admin UI.
const isWpAdmin = /\/wp-admin(\/|$)/.test(location.pathname);
if (detection.context.isLoggedIn && !isWpAdmin) {
loadAdminBarPref().then((hidden) => {
if (hidden) {
if (hideStyle) { // already hidden
console.info(chrome.i18n.getMessage('admin_bar_hidden_notice'));
}
applyHide();
} else {
applyShow();
}
});
} else {
applyShow();
}
// -- Block inspector -----------------------------------------------------
// Outlines `wp-block-*` elements on the frontend. Skipped inside wp-admin
// because the editor has its own block tooling.
const blockInspectorSupported = !isWpAdmin;
async function loadBlockInspectorPref() {
const prefsRoot = await loadPrefsRoot();
const prefs = prefsRoot[location.origin];
const globalPrefs = prefsRoot._global || {};
// Same per-origin-then-_global precedence as loadAdminBarPref. The popup's
// usePrefs merges `_global` for every key, so reading only the per-origin
// namespace here would let its toggle read on while the page never outlines.
if (prefs && typeof prefs.blockInspectorEnabled === 'boolean') {
return prefs.blockInspectorEnabled === true;
}
return globalPrefs.blockInspectorEnabled === true;
}
function applyBlockInspector(enabled) {
if (!blockInspectorSupported || !globalThis.WPDBlockInspector) return;
if (enabled) {
const ctx = detection.context || {};
globalThis.WPDBlockInspector.enable({
isLoggedIn: !!ctx.isLoggedIn,
postId: ctx.postId,
postType: ctx.postType,
restApiRoot: ctx.restApiRoot,
origin: location.origin,
});
} else {
globalThis.WPDBlockInspector.disable();
}
}
// Gate the pref read on actual WordPress detection so non-WP pages don't pay
// a storage round trip for a WordPress-only feature.
if (blockInspectorSupported && detection.isWordPress) {
loadBlockInspectorPref().then((enabled) => {
if (enabled) applyBlockInspector(true);
});
}
// -- Popup messaging -----------------------------------------------------
// Does `resUrl` — a fetch's final URL, after redirects — still name the
// document `target` describes? Origin, pathname, AND query must all match:
// on plain permalinks the query selects the document (?p=1 and ?p=2 are
// different posts). Only the fragment sits outside a document's identity.
// Absent or unparseable counts as a mismatch.
function isSameDocumentResponse(resUrl, target) {
if (!resUrl) return false;
try {
const final = new URL(resUrl, target.href);
return final.origin === target.origin
&& final.pathname === target.pathname
&& final.search === target.search;
} catch (_) {
return false;
}
}
// A nonce the popup resolved is bound to the origin it was read from. The
// popup captures the active tab once, then awaits a MAIN-world read and maybe
// a profile.php fetch before messaging back — the tab can navigate in that
// window, and the message would then be delivered to whatever document now
// holds the tab id. Only honor the nonce when its source origin still equals
// this document's origin, so a victim origin's nonce can't be replayed from
// an attacker document that took over the tab. This is the authoritative
// check: only the receiving document knows its own origin at delivery time.
function nonceForThisDocument(msg) {
return msg && msg.nonce && msg.nonceOrigin === location.origin ? msg.nonce : null;
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (!msg) return;
if (msg.type === 'GET_LIVE_DETECTION') {
// Re-run detection on demand. The IIFE-scope `detection` captured at
// document_idle goes stale when the user logs in elsewhere and returns
// to a BFCache'd or page-cached version of this URL.
const fresh = globalThis.WPDetect.detectWordPress(document, { origin: location.origin, pathname: location.pathname });
if (!fresh.context.isLoggedIn) {
fresh.context.isLoggedIn =
globalThis.WPDetect.detectLoggedInFromCookies(document.cookie);
}
sendResponse({
url: location.href,
origin: location.origin,
pathname: location.pathname,
detection: fresh,
});
return;
}
if (msg.type === 'GET_FRESH_DETECTION') {
// Bypasses the browser HTTP cache and BFCache via cache: 'no-store'
// and re-runs detection against the freshly fetched HTML. Used by the
// popup when the live DOM is stale (admin bar missing despite the user
// being logged in per chrome.cookies). Page caches that vary on the
// auth cookie return logged-in HTML here; ones that don't return the
// same stale response, in which case the popup falls back to a reload
// prompt.
//
// Capture the target once: the gate below and the values handed to
// detection must describe one document, and `location` can move under a
// pushState navigation mid-fetch.
const target = new URL(location.href);
fetch(target.href, {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
.then(async (res) => {
if (!res.ok) return sendResponse({ detection: null });
// A redirect can land this credentialed request on a document the
// page load never saw — a login wall, another install on the host,
// another origin. The popup keys what comes back to the captured tab,
// so parsing it would attribute another install's admin bar and base
// to this tab. Fail closed; the popup keeps what it rendered.
if (!isSameDocumentResponse(res.url, target)) {
return sendResponse({ detection: null });
}
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
// Parsed via DOMParser → no defaultView. Pass the captured origin
// and pathname explicitly: the origin so the +New same-origin filter
// can validate hrefs, the pathname so the wp-admin base fallback
// works (the gate above confirmed both describe this document).
const det = globalThis.WPDetect.detectWordPress(doc, {
origin: target.origin,
pathname: target.pathname,
});
if (!det.context.isLoggedIn) {
det.context.isLoggedIn =
globalThis.WPDetect.detectLoggedInFromCookies(document.cookie);
}
sendResponse({ detection: det });
})
.catch(() => sendResponse({ detection: null }));
return true; // async
}
if (msg.type === 'APPLY_ADMIN_BAR_PREF') {
// Never toggle the admin bar inside wp-admin.
if (!isWpAdmin) {
if (msg.hidden) applyHide();
else applyShow();
}
sendResponse({ ok: true });
return;
}
if (msg.type === 'APPLY_BLOCK_INSPECTOR') {
applyBlockInspector(!!msg.enabled);
sendResponse({ ok: true });
return;
}
if (msg.type === 'RESOLVE_HOST_HEADERS') {
const target = new URL(location.href);
// Same-origin HEAD request — cookies flow, no CORS, minimal payload.
fetch(target.href, {
method: 'HEAD',
credentials: 'include',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
.then((res) => {
if (!isSameDocumentResponse(res.url, target)) {
sendResponse({ host: null });
return;
}
const host = globalThis.WPHost.detectHostFromHeaders(res.headers);
sendResponse({ host });
})
.catch(() => sendResponse({ host: null }));
return true;
}
if (msg.type === 'RESOLVE_EDIT_URL_REST') {
// Async — return true to keep the message channel open while we
// hit the REST API from the page context (same-origin, cookies
// flow for free).
globalThis.WPRest
.resolveEditUrlAsync(detection.context, location.origin)
.then((url) => sendResponse({ url: url || null }))
.catch(() => sendResponse({ url: null }));
return true;
}
if (msg.type === 'RESOLVE_TEMPLATE_EDIT_URL') {
// Template-backed views (blog index, archives) on block themes resolve
// to a site-editor deep link. Needs an authenticated REST round-trip
// (/themes + /templates are private), so the popup passes the nonce it
// already resolves for the site-info/user endpoints.
const nonce = nonceForThisDocument(msg);
globalThis.WPRest
.resolveTemplateEditUrlAsync({ ctx: detection.context, origin: location.origin, nonce })
.then((res) => sendResponse(res || { url: null, isBlockTheme: null }))
.catch(() => sendResponse({ url: null, isBlockTheme: null }));
return true;
}
if (msg.type === 'GET_SITE_INFO') {
// Runs from the page context, so cookies flow automatically. The popup
// pre-reads window.wpApiSettings.nonce via chrome.scripting (MAIN world,
// which content scripts can't touch directly) and passes it in — WP's
// /wp/v2/themes and /wp/v2/plugins reject cookie auth without the
// X-WP-Nonce header even for read-only GETs.
const ctx = detection.context;
const nonce = nonceForThisDocument(msg);
Promise.all([
globalThis.WPRest.fetchSiteInfo({
restApiRoot: ctx.restApiRoot, origin: location.origin, nonce,
}),
globalThis.WPRest.fetchActiveTheme({
restApiRoot: ctx.restApiRoot, origin: location.origin, nonce,
}),
globalThis.WPRest.fetchPluginsDetail({
restApiRoot: ctx.restApiRoot, origin: location.origin, nonce,
}),
]).then(([siteInfo, activeTheme, plugins]) => {
sendResponse({ siteInfo, activeTheme, plugins });
}).catch(() => sendResponse({ siteInfo: null, activeTheme: null, plugins: null }));
return true;
}
if (msg.type === 'GET_CURRENT_USER') {
// Fetches /wp/v2/users/me?context=edit. The nonce arrives from the
// popup via the same MAIN-world extraction that GET_SITE_INFO uses.
const ctx = detection.context;
const nonce = nonceForThisDocument(msg);
globalThis.WPRest
.fetchCurrentUser({ restApiRoot: ctx.restApiRoot, origin: location.origin, nonce })
.then((user) => sendResponse({ user }))
.catch(() => sendResponse({ user: null }));
return true;
}
if (msg.type === 'TOGGLE_QUERY_MONITOR') {
// QM toggles its main panel via a click on the admin-bar link, OR
// directly via the `.qm-show` class on #query-monitor-main. The
// admin-bar click path is preferred because it also handles the
// keyboard focus trap QM sets up; we fall back to the class toggle
// when the admin bar isn't rendered (user hid it).
const barLink = document.querySelector('#wp-admin-bar-query-monitor > a');
const panel = document.getElementById('query-monitor-main');
if (barLink) {
barLink.click();
} else if (panel) {
panel.classList.toggle('qm-show');
panel.classList.toggle('qm-peek', false);
}
sendResponse({ ok: !!(barLink || panel) });
return;
}
});
})();