diff --git a/api/indexes/strapi-docs/search.js b/api/indexes/strapi-docs/search.js new file mode 100644 index 0000000000..d534f19c6d --- /dev/null +++ b/api/indexes/strapi-docs/search.js @@ -0,0 +1,53 @@ +export default async function handler(req, res) { + if (req.method !== 'POST') { + res.status(405).json({ error: 'Method Not Allowed' }); + return; + } + + try { + const { indexUid } = req.query || {}; + // Enforce index name from path folder + if (indexUid !== 'strapi-docs') { + res.status(400).json({ error: 'Invalid indexUid' }); + return; + } + + // Read Meilisearch project URL and key from headers or env + const hostHeader = req.headers['x-meili-host']; + const keyHeader = req.headers['x-meili-api-key']; + const host = (typeof hostHeader === 'string' && hostHeader) || process.env.MEILI_HOST || process.env.NEXT_PUBLIC_MEILI_HOST; + const apiKey = (typeof keyHeader === 'string' && keyHeader) || process.env.MEILI_API_KEY || process.env.NEXT_PUBLIC_MEILI_API_KEY; + + if (!host || !apiKey) { + res.status(500).json({ error: 'Meilisearch host or API key not configured' }); + return; + } + + // Forward X-MS-USER-ID if present from the browser request (same-origin; no CORS issue) + const userId = req.headers['x-ms-user-id']; + + const url = new URL(`/indexes/strapi-docs/search`, host); + const upstream = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + 'X-Meilisearch-Client': req.headers['x-meilisearch-client'] || 'StrapiDocs Proxy', + ...(userId ? { 'X-MS-USER-ID': String(userId) } : {}), + }, + body: JSON.stringify(req.body || {}), + }); + + const body = await upstream.text(); + res.status(upstream.status); + try { + res.setHeader('Content-Type', 'application/json'); + res.send(body); + } catch { + res.json({ error: 'Invalid upstream response' }); + } + } catch (e) { + res.status(500).json({ error: e.message || 'Proxy error' }); + } +} + diff --git a/docusaurus/docs/cms/usage-information.md b/docusaurus/docs/cms/usage-information.md index 62196614f7..9cd1972932 100644 --- a/docusaurus/docs/cms/usage-information.md +++ b/docusaurus/docs/cms/usage-information.md @@ -27,7 +27,7 @@ However, these above actions alone are often insufficient to maintain an overall Without these metrics, we wouldn't be able to make the right choices as we continue to move forward with the roadmap and provide what you, the community and users, are asking for. -## Collected data +## Collected Strapi-related data The following data is collected: @@ -82,3 +82,22 @@ Data collection can later be re-enabled by deleting the flag or setting it to fa :::note If you have any questions or concerns regarding data collection, please contact us at the following email address [privacy@strapi.io](mailto:privacy@strapi.io). ::: + +## Collected search-related data for docs.strapi.io + +To improve our documentation, the public website at `docs.strapi.io` collects anonymous search usage metrics using Meilisearch Cloud. These metrics help us understand how the search performs and where we can make it better: + +- Total searches +- Total users (estimated) +- Most searched keywords +- Searches without results + +To make the “Total users” metric more accurate while preserving privacy, the site creates a pseudonymous identifier in the browser’s localStorage under the `msUserId` key. It is randomly generated, contains no personal data, rotates on the first visit of each calendar month (UTC), and is sent only with documentation search requests as the `X-MS-USER-ID` header. It is not used for any other purpose. + +We do not send click-through or conversion events to Meilisearch. + +### Opt-out + +If your browser’s Do Not Track setting is enabled, the site does not create or send this identifier. + +If this identifier is created, you can remove it at any time by clearing the `msUserId` entry from your browser’s localStorage for docs.strapi.io. \ No newline at end of file diff --git a/docusaurus/sidebars.js b/docusaurus/sidebars.js index 03b7a41e58..cdc5ace439 100644 --- a/docusaurus/sidebars.js +++ b/docusaurus/sidebars.js @@ -363,6 +363,7 @@ const sidebars = { type: 'doc', id: 'cms/customization', // TODO: rename to Introduction label: 'Introduction', + // key: 'cms-customization-introduction', }, 'cms/configurations/functions', { @@ -376,6 +377,7 @@ const sidebars = { type: 'doc', id: 'cms/backend-customization', label: 'Overview', + // key: 'cms-backend-customization-overview', }, 'cms/backend-customization/requests-responses', 'cms/backend-customization/routes', @@ -415,6 +417,7 @@ const sidebars = { type: 'doc', id: 'cms/admin-panel-customization', label: 'Overview', + // key: 'cms-admin-panel-customization-overview', }, 'cms/admin-panel-customization/logos', 'cms/admin-panel-customization/favicon', @@ -457,6 +460,7 @@ const sidebars = { type: 'doc', id: 'cms/typescript', label: 'Introduction', + // key: 'cms-typescript-introduction', }, { type: 'doc', diff --git a/docusaurus/src/theme/SearchBar/index.js b/docusaurus/src/theme/SearchBar/index.js index 0929206a4b..4eaf4fcc2d 100644 --- a/docusaurus/src/theme/SearchBar/index.js +++ b/docusaurus/src/theme/SearchBar/index.js @@ -10,11 +10,56 @@ function SearchBarContent() { const dropdownRef = useRef(null); const searchInstanceRef = useRef(null); const [isLoaded, setIsLoaded] = useState(false); + const originalFetchRef = useRef(null); - useEffect(() => { - if (!searchButtonRef.current) { - return; + function isDoNotTrackEnabled() { + try { + const dnt = (navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack || '').toString(); + return dnt === '1' || dnt.toLowerCase() === 'yes'; + } catch (_) { + return false; + } + } + + function getOrCreateMonthlyUserId() { + if (isDoNotTrackEnabled()) return null; + try { + const key = 'msUserId'; + const now = new Date(); + const monthKey = now.toISOString().slice(0, 7); // YYYY-MM (UTC) + const raw = window.localStorage.getItem(key); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (parsed && parsed.id && parsed.month === monthKey) { + return parsed.id; + } + } catch {} + } + const uuid = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : Math.random().toString(36).slice(2) + Date.now().toString(36); + const value = JSON.stringify({ id: uuid, month: monthKey }); + window.localStorage.setItem(key, value); + return uuid; + } catch (_) { + return null; } + } + + function shouldAttachUserIdHeader(urlStr) { + try { + const meiliHost = siteConfig?.customFields?.meilisearch?.host; + if (!meiliHost) return false; + const u = new URL(urlStr, window.location.origin); + const meili = new URL(meiliHost); + if (u.origin !== meili.origin) return false; + // Only for search requests + return /\/indexes\/[^/]+\/search$/.test(u.pathname); + } catch { + return false; + } + } + + useEffect(() => { const handleKeyDown = (e) => { const kapaContainer = document.getElementById('kapa-widget-container'); @@ -40,22 +85,86 @@ function SearchBarContent() { document.addEventListener('keydown', handleKeyDown, true); + // Prepare pseudonymous monthly user id (respects DNT) + const userId = getOrCreateMonthlyUserId(); + + // Scoped fetch interceptor to add X-MS-USER-ID for Meilisearch search requests + if (typeof window !== 'undefined' && window.fetch && !originalFetchRef.current) { + originalFetchRef.current = window.fetch.bind(window); + window.fetch = async (input, init) => { + try { + const url = typeof input === 'string' ? input : (input && input.url) ? input.url : ''; + if (!userId || !shouldAttachUserIdHeader(url)) { + return originalFetchRef.current(input, init); + } + + // Attach header depending on input type + if (typeof input === 'string' || input instanceof URL) { + const headers = new Headers(init && init.headers ? init.headers : undefined); + if (!headers.has('X-MS-USER-ID')) headers.set('X-MS-USER-ID', userId); + return originalFetchRef.current(input, { ...(init || {}), headers }); + } + + // input is Request + const req = input; + const headers = new Headers(req.headers); + if (!headers.has('X-MS-USER-ID')) headers.set('X-MS-USER-ID', userId); + const newReq = new Request(req, { headers }); + return originalFetchRef.current(newReq); + } catch (_) { + return originalFetchRef.current(input, init); + } + }; + } + + // Also patch XMLHttpRequest for libraries that use XHR under the hood + const originalXHROpen = (typeof XMLHttpRequest !== 'undefined' && XMLHttpRequest.prototype.open) ? XMLHttpRequest.prototype.open : null; + const originalXHRSend = (typeof XMLHttpRequest !== 'undefined' && XMLHttpRequest.prototype.send) ? XMLHttpRequest.prototype.send : null; + let xhrPatched = false; + if (originalXHROpen && originalXHRSend) { + try { + XMLHttpRequest.prototype.open = function(method, url, async, user, password) { + try { this.__ms_url = url; } catch {} + return originalXHROpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function(body) { + try { + if (userId && this && typeof this.setRequestHeader === 'function') { + const url = this.__ms_url || ''; + if (shouldAttachUserIdHeader(url)) { + // Only set if not already set + try { this.setRequestHeader('X-MS-USER-ID', userId); } catch {} + } + } + } catch {} + return originalXHRSend.apply(this, arguments); + }; + xhrPatched = true; + } catch {} + } + if (searchInstanceRef.current) { searchInstanceRef.current.destroy?.(); searchInstanceRef.current = null; } - - searchButtonRef.current.innerHTML = ''; - - Promise.all([ - import('meilisearch-docsearch'), - import('meilisearch-docsearch/css') - ]).then(([{ docsearch }]) => { - const search = docsearch({ - container: searchButtonRef.current, - host: siteConfig.customFields.meilisearch.host, - apiKey: siteConfig.customFields.meilisearch.apiKey, - indexUid: siteConfig.customFields.meilisearch.indexUid, + + if (searchButtonRef.current) { + searchButtonRef.current.innerHTML = ''; + } + + // Initialize docsearch only when container is ready + if (searchButtonRef.current) { + Promise.all([ + import('meilisearch-docsearch'), + import('meilisearch-docsearch/css') + ]).then(([{ docsearch }]) => { + const baseOptions = { + container: searchButtonRef.current, + // Route through same-origin API to add headers server-side without CORS + host: `${window.location.origin}/api`, + // dummy key for docsearch client; real key sent via header to API + apiKey: 'public', + indexUid: siteConfig.customFields.meilisearch.indexUid, transformItems: (items) => { return items.map((item) => { @@ -135,22 +244,55 @@ function SearchBarContent() { getMissingResultsUrl: ({ query }) => { return `https://github.com/strapi/documentation/issues/new?title=Missing+search+results+for+${query}`; }, - }); + }; - searchInstanceRef.current = search; - setIsLoaded(true); + // Send X-MS-USER-ID to same-origin API; no CORS preflight restrictions + const meiliHost = siteConfig.customFields.meilisearch.host; + const meiliKey = siteConfig.customFields.meilisearch.apiKey; + baseOptions.requestConfig = { + ...(baseOptions.requestConfig || {}), + headers: { + ...(userId ? { 'X-MS-USER-ID': userId } : {}), + ...(meiliHost ? { 'X-Meili-Host': meiliHost } : {}), + ...(meiliKey ? { 'X-Meili-Api-Key': meiliKey } : {}), + }, + }; + baseOptions.headers = { + ...(baseOptions.headers || {}), + ...(userId ? { 'X-MS-USER-ID': userId } : {}), + ...(meiliHost ? { 'X-Meili-Host': meiliHost } : {}), + ...(meiliKey ? { 'X-Meili-Api-Key': meiliKey } : {}), + }; - if (colorMode === 'dark') { - dropdownRef.current?.classList.add('dark'); - } else { - dropdownRef.current?.classList.remove('dark'); - } - }).catch((error) => { - console.error('Failed to load MeiliSearch:', error); - }); + const search = docsearch(baseOptions); + + searchInstanceRef.current = search; + setIsLoaded(true); + + if (colorMode === 'dark') { + dropdownRef.current?.classList.add('dark'); + } else { + dropdownRef.current?.classList.remove('dark'); + } + }).catch((error) => { + console.error('Failed to load MeiliSearch:', error); + }); + } return () => { document.removeEventListener('keydown', handleKeyDown, true); + if (originalFetchRef.current) { + try { + window.fetch = originalFetchRef.current; + } catch {} + originalFetchRef.current = null; + } + if (xhrPatched && originalXHROpen && originalXHRSend) { + try { + XMLHttpRequest.prototype.open = originalXHROpen; + XMLHttpRequest.prototype.send = originalXHRSend; + } catch {} + } if (searchInstanceRef.current) { searchInstanceRef.current.destroy?.(); searchInstanceRef.current = null; @@ -171,4 +313,4 @@ export default function SearchBar() { {() => } ); -} \ No newline at end of file +}